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