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 { TimezoneSync } from '@/components/TimezoneSync'
export default function CalendarLayout({
children,
@ -8,6 +9,7 @@ export default function CalendarLayout({
return (
<>
<Header current="cal" />
<TimezoneSync />
{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'
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)}

View File

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

View File

@ -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) => {

View File

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

View File

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

View File

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

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
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,
}),
}
)

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