From e480a693f5be11588fa1afd0ba1cb882d672f2ac Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 16 Mar 2026 17:00:29 -0700 Subject: [PATCH] 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 --- src/app/calendar/layout.tsx | 2 ++ src/components/TimezoneSync.tsx | 9 ++++++ src/components/calendar/CalendarHeader.tsx | 13 +++++++- src/components/calendar/DayView.tsx | 13 +++----- src/components/calendar/EventDetailModal.tsx | 9 +++--- src/components/calendar/MonthView.tsx | 11 ++++--- src/components/calendar/SpatioTemporalMap.tsx | 7 ++-- src/components/calendar/WeekView.tsx | 7 ++-- src/hooks/useTimezone.ts | 22 +++++++++++++ src/lib/store.ts | 11 ++++++- src/lib/time-format.ts | 32 +++++++++++++++++++ 11 files changed, 107 insertions(+), 29 deletions(-) create mode 100644 src/components/TimezoneSync.tsx create mode 100644 src/hooks/useTimezone.ts create mode 100644 src/lib/time-format.ts diff --git a/src/app/calendar/layout.tsx b/src/app/calendar/layout.tsx index eae1ff1..ea89b48 100644 --- a/src/app/calendar/layout.tsx +++ b/src/app/calendar/layout.tsx @@ -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 ( <>
+ {children} ) diff --git a/src/components/TimezoneSync.tsx b/src/components/TimezoneSync.tsx new file mode 100644 index 0000000..08beb81 --- /dev/null +++ b/src/components/TimezoneSync.tsx @@ -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 +} diff --git a/src/components/calendar/CalendarHeader.tsx b/src/components/calendar/CalendarHeader.tsx index ac435c4..7d2b675 100644 --- a/src/components/calendar/CalendarHeader.tsx +++ b/src/components/calendar/CalendarHeader.tsx @@ -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
+ {/* Timezone indicator */} +
+ + {getTimezoneAbbreviation(viewerTimezone)} +
+ {/* Show lunar overlay toggle */} diff --git a/src/components/calendar/EventDetailModal.tsx b/src/components/calendar/EventDetailModal.tsx index 9dbb8db..304d52d 100644 --- a/src/components/calendar/EventDetailModal.tsx +++ b/src/components/calendar/EventDetailModal.tsx @@ -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, }) 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) => { diff --git a/src/components/calendar/MonthView.tsx b/src/components/calendar/MonthView.tsx index 118b0a4..c6a7bb4 100644 --- a/src/components/calendar/MonthView.tsx +++ b/src/components/calendar/MonthView.tsx @@ -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} /> ))}
@@ -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 && ( - {new Date(event.start).toLocaleTimeString('en-US', { - hour: 'numeric', - minute: '2-digit', - })} + {formatEventTime(event.start, viewerTimezone)} )} {event.title} diff --git a/src/components/calendar/SpatioTemporalMap.tsx b/src/components/calendar/SpatioTemporalMap.tsx index 4c560da..b95d89b 100644 --- a/src/components/calendar/SpatioTemporalMap.tsx +++ b/src/components/calendar/SpatioTemporalMap.tsx @@ -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 && (
- {new Date(marker.events[0].start).toLocaleTimeString('en-US', { - hour: 'numeric', - minute: '2-digit', - })} + {formatEventTime(marker.events[0].start, viewerTimezone)}
)} diff --git a/src/components/calendar/WeekView.tsx b/src/components/calendar/WeekView.tsx index 7fa7b2b..2cf663d 100644 --- a/src/components/calendar/WeekView.tsx +++ b/src/components/calendar/WeekView.tsx @@ -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() { >
{event.title}
- {new Date(event.start).toLocaleTimeString('en-US', { - hour: 'numeric', - minute: '2-digit', - })} + {formatEventTime(event.start, viewerTimezone)}
) diff --git a/src/hooks/useTimezone.ts b/src/hooks/useTimezone.ts new file mode 100644 index 0000000..d3e9098 --- /dev/null +++ b/src/hooks/useTimezone.ts @@ -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 +} diff --git a/src/lib/store.ts b/src/lib/store.ts index 113a4a1..ca86279 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -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()( } }), + // 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()( hiddenSources: state.hiddenSources, mapVisible: state.mapVisible, zoomCoupled: state.zoomCoupled, + viewerTimezone: state.viewerTimezone, }), } ) diff --git a/src/lib/time-format.ts b/src/lib/time-format.ts new file mode 100644 index 0000000..5545bf4 --- /dev/null +++ b/src/lib/time-format.ts @@ -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 + } +}