feat: add timezone-aware time display across all views
Detects browser timezone, persists in Zustand store, and formats all event times using explicit timezone via Intl.DateTimeFormat. Adds TZ badge to calendar header. Affects DayView, WeekView, MonthView, EventDetailModal, and SpatioTemporalMap. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
10b8b42200
commit
8cb3f804b0
|
|
@ -1,4 +1,5 @@
|
|||
import { Header } from '@/components/Header'
|
||||
import { TimezoneSync } from '@/components/TimezoneSync'
|
||||
|
||||
export default function CalendarLayout({
|
||||
children,
|
||||
|
|
@ -8,6 +9,7 @@ export default function CalendarLayout({
|
|||
return (
|
||||
<>
|
||||
<Header current="cal" />
|
||||
<TimezoneSync />
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
'use client'
|
||||
|
||||
import { useTimezone } from '@/hooks/useTimezone'
|
||||
|
||||
/** Invisible component that syncs browser timezone to store on mount. */
|
||||
export function TimezoneSync() {
|
||||
useTimezone()
|
||||
return null
|
||||
}
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
'use client'
|
||||
|
||||
import { ChevronLeft, ChevronRight, Menu, Calendar, Moon, Settings, MapPin, ZoomIn, ZoomOut } from 'lucide-react'
|
||||
import { ChevronLeft, ChevronRight, Menu, Calendar, Moon, Settings, MapPin, ZoomIn, ZoomOut, Globe } from 'lucide-react'
|
||||
import { useCalendarStore } from '@/lib/store'
|
||||
import { TemporalGranularity, TEMPORAL_GRANULARITY_LABELS } from '@/lib/types'
|
||||
import { getTimezoneAbbreviation } from '@/lib/time-format'
|
||||
import { clsx } from 'clsx'
|
||||
import { AppSwitcher } from '@/components/AppSwitcher'
|
||||
import { ViewSwitcher } from './ViewSwitcher'
|
||||
|
|
@ -23,6 +24,7 @@ export function CalendarHeader({ onToggleSidebar, sidebarOpen }: CalendarHeaderP
|
|||
zoomOut,
|
||||
showLunarOverlay,
|
||||
setShowLunarOverlay,
|
||||
viewerTimezone,
|
||||
} = useCalendarStore()
|
||||
|
||||
// Format the display based on temporal granularity
|
||||
|
|
@ -142,6 +144,15 @@ export function CalendarHeader({ onToggleSidebar, sidebarOpen }: CalendarHeaderP
|
|||
<div className="flex items-center gap-3">
|
||||
<ViewSwitcher />
|
||||
|
||||
{/* Timezone indicator */}
|
||||
<div
|
||||
className="flex items-center gap-1 px-2 py-1.5 rounded-lg bg-gray-100 dark:bg-gray-700 text-xs font-medium text-gray-600 dark:text-gray-300"
|
||||
title={viewerTimezone}
|
||||
>
|
||||
<Globe className="w-3.5 h-3.5" />
|
||||
{getTimezoneAbbreviation(viewerTimezone)}
|
||||
</div>
|
||||
|
||||
{/* Show lunar overlay toggle */}
|
||||
<button
|
||||
onClick={() => setShowLunarOverlay(!showLunarOverlay)}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useMemo } from 'react'
|
|||
import { useCalendarStore, useEffectiveSpatialGranularity } from '@/lib/store'
|
||||
import { getSemanticLocationLabel } from '@/lib/location'
|
||||
import { useMonthEvents } from '@/hooks/useEvents'
|
||||
import { formatEventTime } from '@/lib/time-format'
|
||||
import type { EventListItem } from '@/lib/types'
|
||||
import { EventDetailModal } from './EventDetailModal'
|
||||
import { clsx } from 'clsx'
|
||||
|
|
@ -38,7 +39,7 @@ function getEventPosition(event: EventListItem, dayStart: Date) {
|
|||
}
|
||||
|
||||
export function DayView() {
|
||||
const { currentDate, selectedEventId, setSelectedEventId, hiddenSources } = useCalendarStore()
|
||||
const { currentDate, selectedEventId, setSelectedEventId, hiddenSources, viewerTimezone } = useCalendarStore()
|
||||
const effectiveSpatial = useEffectiveSpatialGranularity()
|
||||
|
||||
const year = currentDate.getFullYear()
|
||||
|
|
@ -182,15 +183,9 @@ export function DayView() {
|
|||
>
|
||||
<div className="text-xs font-medium truncate">{event.title}</div>
|
||||
<div className="text-[10px] opacity-75 truncate">
|
||||
{new Date(event.start).toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
{formatEventTime(event.start, viewerTimezone)}
|
||||
{' - '}
|
||||
{new Date(event.end).toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
{formatEventTime(event.end, viewerTimezone)}
|
||||
{locationLabel && ` \u00B7 ${locationLabel}`}
|
||||
</div>
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ import { useQuery } from '@tanstack/react-query'
|
|||
import { getEvent } from '@/lib/api'
|
||||
import { getSemanticLocationLabel } from '@/lib/location'
|
||||
import type { UnifiedEvent } from '@/lib/types'
|
||||
import { useEffectiveSpatialGranularity } from '@/lib/store'
|
||||
import { useCalendarStore, useEffectiveSpatialGranularity } from '@/lib/store'
|
||||
import { formatEventTime } from '@/lib/time-format'
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
interface EventDetailModalProps {
|
||||
|
|
@ -19,12 +20,10 @@ export function EventDetailModal({ eventId, onClose }: EventDetailModalProps) {
|
|||
queryFn: () => getEvent(eventId) as Promise<UnifiedEvent>,
|
||||
})
|
||||
const effectiveSpatial = useEffectiveSpatialGranularity()
|
||||
const viewerTimezone = useCalendarStore((s) => s.viewerTimezone)
|
||||
|
||||
const formatTime = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
})
|
||||
return formatEventTime(dateStr, viewerTimezone)
|
||||
}
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useMemo } from 'react'
|
|||
import { useCalendarStore, useEffectiveSpatialGranularity } from '@/lib/store'
|
||||
import { getSemanticLocationLabel } from '@/lib/location'
|
||||
import { useMonthEvents, groupEventsByDate } from '@/hooks/useEvents'
|
||||
import { formatEventTime } from '@/lib/time-format'
|
||||
import type { EventListItem } from '@/lib/types'
|
||||
import { EventDetailModal } from './EventDetailModal'
|
||||
import { clsx } from 'clsx'
|
||||
|
|
@ -17,7 +18,7 @@ interface DayCell {
|
|||
}
|
||||
|
||||
export function MonthView() {
|
||||
const { currentDate, showLunarOverlay, selectedEventId, setSelectedEventId, hiddenSources } = useCalendarStore()
|
||||
const { currentDate, showLunarOverlay, selectedEventId, setSelectedEventId, hiddenSources, viewerTimezone } = useCalendarStore()
|
||||
const effectiveSpatial = useEffectiveSpatialGranularity()
|
||||
|
||||
const year = currentDate.getFullYear()
|
||||
|
|
@ -95,6 +96,7 @@ export function MonthView() {
|
|||
isLoading={isLoading}
|
||||
onEventClick={setSelectedEventId}
|
||||
effectiveSpatial={effectiveSpatial}
|
||||
viewerTimezone={viewerTimezone}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -118,6 +120,7 @@ function DayCellComponent({
|
|||
isLoading,
|
||||
onEventClick,
|
||||
effectiveSpatial,
|
||||
viewerTimezone,
|
||||
}: {
|
||||
day: DayCell
|
||||
showLunarOverlay: boolean
|
||||
|
|
@ -125,6 +128,7 @@ function DayCellComponent({
|
|||
isLoading: boolean
|
||||
onEventClick: (eventId: string | null) => void
|
||||
effectiveSpatial: number
|
||||
viewerTimezone: string
|
||||
}) {
|
||||
const isWeekend = day.date.getDay() === 0 || day.date.getDay() === 6
|
||||
const maxVisibleEvents = 3
|
||||
|
|
@ -180,10 +184,7 @@ function DayCellComponent({
|
|||
>
|
||||
{!event.all_day && (
|
||||
<span className="opacity-75 mr-1">
|
||||
{new Date(event.start).toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
{formatEventTime(event.start, viewerTimezone)}
|
||||
</span>
|
||||
)}
|
||||
{event.title}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { MapContainer, TileLayer, CircleMarker, Polyline, Popup, Tooltip, useMap
|
|||
import { useCalendarStore, useEffectiveSpatialGranularity } from '@/lib/store'
|
||||
import { useMapState } from '@/hooks/useMapState'
|
||||
import { getSemanticLocationLabel } from '@/lib/location'
|
||||
import { formatEventTime } from '@/lib/time-format'
|
||||
import { SpatialGranularity, SPATIAL_TO_LEAFLET_ZOOM, GRANULARITY_LABELS, leafletZoomToSpatial } from '@/lib/types'
|
||||
import type { EventListItem } from '@/lib/types'
|
||||
import { clsx } from 'clsx'
|
||||
|
|
@ -60,6 +61,7 @@ function MapController({ center, zoom }: { center: [number, number]; zoom: numbe
|
|||
/** Renders event markers, clustering at broad zooms. */
|
||||
function EventMarkers({ events }: { events: EventListItem[] }) {
|
||||
const spatialGranularity = useEffectiveSpatialGranularity()
|
||||
const viewerTimezone = useCalendarStore((s) => s.viewerTimezone)
|
||||
const isBroadZoom = spatialGranularity <= SpatialGranularity.COUNTRY
|
||||
|
||||
const markers = useMemo(() => {
|
||||
|
|
@ -143,10 +145,7 @@ function EventMarkers({ events }: { events: EventListItem[] }) {
|
|||
)}
|
||||
{!marker.events[0].all_day && (
|
||||
<div className="text-xs text-gray-500 mt-0.5">
|
||||
{new Date(marker.events[0].start).toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
{formatEventTime(marker.events[0].start, viewerTimezone)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useMemo } from 'react'
|
|||
import { useCalendarStore, useEffectiveSpatialGranularity } from '@/lib/store'
|
||||
import { getSemanticLocationLabel } from '@/lib/location'
|
||||
import { useMonthEvents } from '@/hooks/useEvents'
|
||||
import { formatEventTime } from '@/lib/time-format'
|
||||
import { TemporalGranularity } from '@/lib/types'
|
||||
import type { EventListItem } from '@/lib/types'
|
||||
import { EventDetailModal } from './EventDetailModal'
|
||||
|
|
@ -57,6 +58,7 @@ export function WeekView() {
|
|||
setCurrentDate,
|
||||
setTemporalGranularity,
|
||||
hiddenSources,
|
||||
viewerTimezone,
|
||||
} = useCalendarStore()
|
||||
const effectiveSpatial = useEffectiveSpatialGranularity()
|
||||
|
||||
|
|
@ -267,10 +269,7 @@ export function WeekView() {
|
|||
>
|
||||
<div className="text-[10px] font-medium truncate">{event.title}</div>
|
||||
<div className="text-[9px] opacity-75 truncate">
|
||||
{new Date(event.start).toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
{formatEventTime(event.start, viewerTimezone)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useCalendarStore } from '@/lib/store'
|
||||
|
||||
/**
|
||||
* Detects the browser's timezone and syncs it to the store.
|
||||
* No API calls — rcal-online is view-only.
|
||||
*/
|
||||
export function useTimezone() {
|
||||
const viewerTimezone = useCalendarStore((s) => s.viewerTimezone)
|
||||
const setViewerTimezone = useCalendarStore((s) => s.setViewerTimezone)
|
||||
|
||||
useEffect(() => {
|
||||
const browserTz = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
if (browserTz && browserTz !== viewerTimezone) {
|
||||
setViewerTimezone(browserTz)
|
||||
}
|
||||
}, [viewerTimezone, setViewerTimezone])
|
||||
|
||||
return viewerTimezone
|
||||
}
|
||||
|
|
@ -69,6 +69,10 @@ interface CalendarState {
|
|||
setZoomCoupled: (coupled: boolean) => void
|
||||
toggleZoomCoupled: () => void
|
||||
|
||||
// Viewer timezone
|
||||
viewerTimezone: string
|
||||
setViewerTimezone: (tz: string) => void
|
||||
|
||||
// r* tool context
|
||||
rCalContext: RCalContext | null
|
||||
setRCalContext: (context: RCalContext | null) => void
|
||||
|
|
@ -250,13 +254,17 @@ export const useCalendarStore = create<CalendarState>()(
|
|||
}
|
||||
}),
|
||||
|
||||
// Viewer timezone
|
||||
viewerTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
|
||||
setViewerTimezone: (viewerTimezone) => set({ viewerTimezone }),
|
||||
|
||||
// r* tool context
|
||||
rCalContext: null,
|
||||
setRCalContext: (rCalContext) => set({ rCalContext }),
|
||||
}),
|
||||
{
|
||||
name: 'calendar-store',
|
||||
version: 3,
|
||||
version: 4,
|
||||
partialize: (state) => ({
|
||||
calendarType: state.calendarType,
|
||||
viewType: state.viewType,
|
||||
|
|
@ -267,6 +275,7 @@ export const useCalendarStore = create<CalendarState>()(
|
|||
hiddenSources: state.hiddenSources,
|
||||
mapVisible: state.mapVisible,
|
||||
zoomCoupled: state.zoomCoupled,
|
||||
viewerTimezone: state.viewerTimezone,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
/**
|
||||
* Timezone-aware time formatting utilities.
|
||||
* Uses Intl.DateTimeFormat (built-in, no dependencies).
|
||||
*/
|
||||
|
||||
export function formatEventTime(isoString: string, timezone: string): string {
|
||||
return new Date(isoString).toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
timeZone: timezone,
|
||||
})
|
||||
}
|
||||
|
||||
export function formatEventTimeRange(
|
||||
start: string,
|
||||
end: string,
|
||||
timezone: string
|
||||
): string {
|
||||
return `${formatEventTime(start, timezone)} \u2013 ${formatEventTime(end, timezone)}`
|
||||
}
|
||||
|
||||
export function getTimezoneAbbreviation(timezone: string): string {
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: timezone,
|
||||
timeZoneName: 'short',
|
||||
}).formatToParts(new Date())
|
||||
return parts.find((p) => p.type === 'timeZoneName')?.value || timezone
|
||||
} catch {
|
||||
return timezone
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue