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