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:
Jeff Emmett 2026-03-16 17:00:29 -07:00
parent 10b8b42200
commit 8cb3f804b0
11 changed files with 107 additions and 29 deletions

View File

@ -1,4 +1,5 @@
import { Header } from '@/components/Header' import { Header } from '@/components/Header'
import { TimezoneSync } from '@/components/TimezoneSync'
export default function CalendarLayout({ export default function CalendarLayout({
children, children,
@ -8,6 +9,7 @@ export default function CalendarLayout({
return ( return (
<> <>
<Header current="cal" /> <Header current="cal" />
<TimezoneSync />
{children} {children}
</> </>
) )

View File

@ -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
}

View File

@ -1,8 +1,9 @@
'use client' '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 { useCalendarStore } from '@/lib/store'
import { TemporalGranularity, TEMPORAL_GRANULARITY_LABELS } from '@/lib/types' import { TemporalGranularity, TEMPORAL_GRANULARITY_LABELS } from '@/lib/types'
import { getTimezoneAbbreviation } from '@/lib/time-format'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { AppSwitcher } from '@/components/AppSwitcher' import { AppSwitcher } from '@/components/AppSwitcher'
import { ViewSwitcher } from './ViewSwitcher' import { ViewSwitcher } from './ViewSwitcher'
@ -23,6 +24,7 @@ export function CalendarHeader({ onToggleSidebar, sidebarOpen }: CalendarHeaderP
zoomOut, zoomOut,
showLunarOverlay, showLunarOverlay,
setShowLunarOverlay, setShowLunarOverlay,
viewerTimezone,
} = useCalendarStore() } = useCalendarStore()
// Format the display based on temporal granularity // Format the display based on temporal granularity
@ -142,6 +144,15 @@ export function CalendarHeader({ onToggleSidebar, sidebarOpen }: CalendarHeaderP
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<ViewSwitcher /> <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 */} {/* Show lunar overlay toggle */}
<button <button
onClick={() => setShowLunarOverlay(!showLunarOverlay)} onClick={() => setShowLunarOverlay(!showLunarOverlay)}

View File

@ -4,6 +4,7 @@ import { useMemo } from 'react'
import { useCalendarStore, useEffectiveSpatialGranularity } from '@/lib/store' import { useCalendarStore, useEffectiveSpatialGranularity } from '@/lib/store'
import { getSemanticLocationLabel } from '@/lib/location' import { getSemanticLocationLabel } from '@/lib/location'
import { useMonthEvents } from '@/hooks/useEvents' import { useMonthEvents } from '@/hooks/useEvents'
import { formatEventTime } from '@/lib/time-format'
import type { EventListItem } from '@/lib/types' import type { EventListItem } from '@/lib/types'
import { EventDetailModal } from './EventDetailModal' import { EventDetailModal } from './EventDetailModal'
import { clsx } from 'clsx' import { clsx } from 'clsx'
@ -38,7 +39,7 @@ function getEventPosition(event: EventListItem, dayStart: Date) {
} }
export function DayView() { export function DayView() {
const { currentDate, selectedEventId, setSelectedEventId, hiddenSources } = useCalendarStore() const { currentDate, selectedEventId, setSelectedEventId, hiddenSources, viewerTimezone } = useCalendarStore()
const effectiveSpatial = useEffectiveSpatialGranularity() const effectiveSpatial = useEffectiveSpatialGranularity()
const year = currentDate.getFullYear() const year = currentDate.getFullYear()
@ -182,15 +183,9 @@ export function DayView() {
> >
<div className="text-xs font-medium truncate">{event.title}</div> <div className="text-xs font-medium truncate">{event.title}</div>
<div className="text-[10px] opacity-75 truncate"> <div className="text-[10px] opacity-75 truncate">
{new Date(event.start).toLocaleTimeString('en-US', { {formatEventTime(event.start, viewerTimezone)}
hour: 'numeric',
minute: '2-digit',
})}
{' - '} {' - '}
{new Date(event.end).toLocaleTimeString('en-US', { {formatEventTime(event.end, viewerTimezone)}
hour: 'numeric',
minute: '2-digit',
})}
{locationLabel && ` \u00B7 ${locationLabel}`} {locationLabel && ` \u00B7 ${locationLabel}`}
</div> </div>
</button> </button>

View File

@ -5,7 +5,8 @@ import { useQuery } from '@tanstack/react-query'
import { getEvent } from '@/lib/api' import { getEvent } from '@/lib/api'
import { getSemanticLocationLabel } from '@/lib/location' import { getSemanticLocationLabel } from '@/lib/location'
import type { UnifiedEvent } from '@/lib/types' 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' import { clsx } from 'clsx'
interface EventDetailModalProps { interface EventDetailModalProps {
@ -19,12 +20,10 @@ export function EventDetailModal({ eventId, onClose }: EventDetailModalProps) {
queryFn: () => getEvent(eventId) as Promise<UnifiedEvent>, queryFn: () => getEvent(eventId) as Promise<UnifiedEvent>,
}) })
const effectiveSpatial = useEffectiveSpatialGranularity() const effectiveSpatial = useEffectiveSpatialGranularity()
const viewerTimezone = useCalendarStore((s) => s.viewerTimezone)
const formatTime = (dateStr: string) => { const formatTime = (dateStr: string) => {
return new Date(dateStr).toLocaleTimeString('en-US', { return formatEventTime(dateStr, viewerTimezone)
hour: 'numeric',
minute: '2-digit',
})
} }
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {

View File

@ -4,6 +4,7 @@ import { useMemo } from 'react'
import { useCalendarStore, useEffectiveSpatialGranularity } from '@/lib/store' import { useCalendarStore, useEffectiveSpatialGranularity } from '@/lib/store'
import { getSemanticLocationLabel } from '@/lib/location' import { getSemanticLocationLabel } from '@/lib/location'
import { useMonthEvents, groupEventsByDate } from '@/hooks/useEvents' import { useMonthEvents, groupEventsByDate } from '@/hooks/useEvents'
import { formatEventTime } from '@/lib/time-format'
import type { EventListItem } from '@/lib/types' import type { EventListItem } from '@/lib/types'
import { EventDetailModal } from './EventDetailModal' import { EventDetailModal } from './EventDetailModal'
import { clsx } from 'clsx' import { clsx } from 'clsx'
@ -17,7 +18,7 @@ interface DayCell {
} }
export function MonthView() { export function MonthView() {
const { currentDate, showLunarOverlay, selectedEventId, setSelectedEventId, hiddenSources } = useCalendarStore() const { currentDate, showLunarOverlay, selectedEventId, setSelectedEventId, hiddenSources, viewerTimezone } = useCalendarStore()
const effectiveSpatial = useEffectiveSpatialGranularity() const effectiveSpatial = useEffectiveSpatialGranularity()
const year = currentDate.getFullYear() const year = currentDate.getFullYear()
@ -95,6 +96,7 @@ export function MonthView() {
isLoading={isLoading} isLoading={isLoading}
onEventClick={setSelectedEventId} onEventClick={setSelectedEventId}
effectiveSpatial={effectiveSpatial} effectiveSpatial={effectiveSpatial}
viewerTimezone={viewerTimezone}
/> />
))} ))}
</div> </div>
@ -118,6 +120,7 @@ function DayCellComponent({
isLoading, isLoading,
onEventClick, onEventClick,
effectiveSpatial, effectiveSpatial,
viewerTimezone,
}: { }: {
day: DayCell day: DayCell
showLunarOverlay: boolean showLunarOverlay: boolean
@ -125,6 +128,7 @@ function DayCellComponent({
isLoading: boolean isLoading: boolean
onEventClick: (eventId: string | null) => void onEventClick: (eventId: string | null) => void
effectiveSpatial: number effectiveSpatial: number
viewerTimezone: string
}) { }) {
const isWeekend = day.date.getDay() === 0 || day.date.getDay() === 6 const isWeekend = day.date.getDay() === 0 || day.date.getDay() === 6
const maxVisibleEvents = 3 const maxVisibleEvents = 3
@ -180,10 +184,7 @@ function DayCellComponent({
> >
{!event.all_day && ( {!event.all_day && (
<span className="opacity-75 mr-1"> <span className="opacity-75 mr-1">
{new Date(event.start).toLocaleTimeString('en-US', { {formatEventTime(event.start, viewerTimezone)}
hour: 'numeric',
minute: '2-digit',
})}
</span> </span>
)} )}
{event.title} {event.title}

View File

@ -6,6 +6,7 @@ import { MapContainer, TileLayer, CircleMarker, Polyline, Popup, Tooltip, useMap
import { useCalendarStore, useEffectiveSpatialGranularity } from '@/lib/store' import { useCalendarStore, useEffectiveSpatialGranularity } from '@/lib/store'
import { useMapState } from '@/hooks/useMapState' import { useMapState } from '@/hooks/useMapState'
import { getSemanticLocationLabel } from '@/lib/location' import { getSemanticLocationLabel } from '@/lib/location'
import { formatEventTime } from '@/lib/time-format'
import { SpatialGranularity, SPATIAL_TO_LEAFLET_ZOOM, GRANULARITY_LABELS, leafletZoomToSpatial } from '@/lib/types' import { SpatialGranularity, SPATIAL_TO_LEAFLET_ZOOM, GRANULARITY_LABELS, leafletZoomToSpatial } from '@/lib/types'
import type { EventListItem } from '@/lib/types' import type { EventListItem } from '@/lib/types'
import { clsx } from 'clsx' import { clsx } from 'clsx'
@ -60,6 +61,7 @@ function MapController({ center, zoom }: { center: [number, number]; zoom: numbe
/** Renders event markers, clustering at broad zooms. */ /** Renders event markers, clustering at broad zooms. */
function EventMarkers({ events }: { events: EventListItem[] }) { function EventMarkers({ events }: { events: EventListItem[] }) {
const spatialGranularity = useEffectiveSpatialGranularity() const spatialGranularity = useEffectiveSpatialGranularity()
const viewerTimezone = useCalendarStore((s) => s.viewerTimezone)
const isBroadZoom = spatialGranularity <= SpatialGranularity.COUNTRY const isBroadZoom = spatialGranularity <= SpatialGranularity.COUNTRY
const markers = useMemo(() => { const markers = useMemo(() => {
@ -143,10 +145,7 @@ function EventMarkers({ events }: { events: EventListItem[] }) {
)} )}
{!marker.events[0].all_day && ( {!marker.events[0].all_day && (
<div className="text-xs text-gray-500 mt-0.5"> <div className="text-xs text-gray-500 mt-0.5">
{new Date(marker.events[0].start).toLocaleTimeString('en-US', { {formatEventTime(marker.events[0].start, viewerTimezone)}
hour: 'numeric',
minute: '2-digit',
})}
</div> </div>
)} )}
</> </>

View File

@ -4,6 +4,7 @@ import { useMemo } from 'react'
import { useCalendarStore, useEffectiveSpatialGranularity } from '@/lib/store' import { useCalendarStore, useEffectiveSpatialGranularity } from '@/lib/store'
import { getSemanticLocationLabel } from '@/lib/location' import { getSemanticLocationLabel } from '@/lib/location'
import { useMonthEvents } from '@/hooks/useEvents' import { useMonthEvents } from '@/hooks/useEvents'
import { formatEventTime } from '@/lib/time-format'
import { TemporalGranularity } from '@/lib/types' import { TemporalGranularity } from '@/lib/types'
import type { EventListItem } from '@/lib/types' import type { EventListItem } from '@/lib/types'
import { EventDetailModal } from './EventDetailModal' import { EventDetailModal } from './EventDetailModal'
@ -57,6 +58,7 @@ export function WeekView() {
setCurrentDate, setCurrentDate,
setTemporalGranularity, setTemporalGranularity,
hiddenSources, hiddenSources,
viewerTimezone,
} = useCalendarStore() } = useCalendarStore()
const effectiveSpatial = useEffectiveSpatialGranularity() const effectiveSpatial = useEffectiveSpatialGranularity()
@ -267,10 +269,7 @@ export function WeekView() {
> >
<div className="text-[10px] font-medium truncate">{event.title}</div> <div className="text-[10px] font-medium truncate">{event.title}</div>
<div className="text-[9px] opacity-75 truncate"> <div className="text-[9px] opacity-75 truncate">
{new Date(event.start).toLocaleTimeString('en-US', { {formatEventTime(event.start, viewerTimezone)}
hour: 'numeric',
minute: '2-digit',
})}
</div> </div>
</button> </button>
) )

22
src/hooks/useTimezone.ts Normal file
View File

@ -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
}

View File

@ -69,6 +69,10 @@ interface CalendarState {
setZoomCoupled: (coupled: boolean) => void setZoomCoupled: (coupled: boolean) => void
toggleZoomCoupled: () => void toggleZoomCoupled: () => void
// Viewer timezone
viewerTimezone: string
setViewerTimezone: (tz: string) => void
// r* tool context // r* tool context
rCalContext: RCalContext | null rCalContext: RCalContext | null
setRCalContext: (context: RCalContext | null) => void 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 // r* tool context
rCalContext: null, rCalContext: null,
setRCalContext: (rCalContext) => set({ rCalContext }), setRCalContext: (rCalContext) => set({ rCalContext }),
}), }),
{ {
name: 'calendar-store', name: 'calendar-store',
version: 3, version: 4,
partialize: (state) => ({ partialize: (state) => ({
calendarType: state.calendarType, calendarType: state.calendarType,
viewType: state.viewType, viewType: state.viewType,
@ -267,6 +275,7 @@ export const useCalendarStore = create<CalendarState>()(
hiddenSources: state.hiddenSources, hiddenSources: state.hiddenSources,
mapVisible: state.mapVisible, mapVisible: state.mapVisible,
zoomCoupled: state.zoomCoupled, zoomCoupled: state.zoomCoupled,
viewerTimezone: state.viewerTimezone,
}), }),
} }
) )

32
src/lib/time-format.ts Normal file
View File

@ -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
}
}