From 9f32fc879bd3634e3fb1bbe876264efb6920568e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:15:41 +0200 Subject: [PATCH] feat: add custom time zone setting overriding browser detection #755 --- components/calendar/calendar-agenda-view.tsx | 22 +- components/calendar/calendar-app.tsx | 13 +- components/calendar/calendar-day-view.tsx | 19 +- .../calendar/calendar-sidebar-panel.tsx | 3 +- components/calendar/calendar-week-view.tsx | 23 +- components/calendar/event-modal.tsx | 11 +- components/calendar/mini-calendar.tsx | 5 +- components/calendar/task-list-view.tsx | 9 +- components/files/eml-preview.tsx | 3 +- components/files/file-browser.tsx | 2 + components/providers/intl-provider.tsx | 19 +- components/settings/language-settings.tsx | 61 ++++- hooks/use-calendar-locale.ts | 6 +- hooks/use-display-date-formatter.ts | 26 ++ hooks/use-time-grid-interactions.ts | 9 +- lib/__tests__/timezone.test.ts | 175 +++++++++++++ lib/calendar-utils.ts | 13 +- lib/jmap/client.ts | 6 +- lib/timezone.ts | 232 ++++++++++++++++++ lib/utils.ts | 27 +- locales/ar/common.json | 6 + locales/ca/common.json | 6 + locales/cs/common.json | 6 + locales/da/common.json | 6 + locales/de/common.json | 6 + locales/en/common.json | 6 + locales/es/common.json | 6 + locales/fa/common.json | 6 + locales/fr/common.json | 6 + locales/he/common.json | 6 + locales/hu/common.json | 6 + locales/it/common.json | 6 + locales/ja/common.json | 6 + locales/ko/common.json | 6 + locales/lv/common.json | 6 + locales/mn/common.json | 6 + locales/nl/common.json | 6 + locales/pl/common.json | 6 + locales/pt/common.json | 6 + locales/ro/common.json | 6 + locales/ru/common.json | 6 + locales/sk/common.json | 6 + locales/tr/common.json | 6 + locales/uk/common.json | 6 + locales/zh/common.json | 6 + stores/calendar-store.ts | 7 +- stores/settings-store.ts | 11 + 47 files changed, 776 insertions(+), 76 deletions(-) create mode 100644 hooks/use-display-date-formatter.ts create mode 100644 lib/__tests__/timezone.test.ts create mode 100644 lib/timezone.ts diff --git a/components/calendar/calendar-agenda-view.tsx b/components/calendar/calendar-agenda-view.tsx index d757305c7..d61979d80 100644 --- a/components/calendar/calendar-agenda-view.tsx +++ b/components/calendar/calendar-agenda-view.tsx @@ -1,12 +1,14 @@ "use client"; import { useMemo, useRef, useEffect, useCallback } from "react"; -import { useTranslations, useFormatter } from "next-intl"; -import { format, isToday, isTomorrow, startOfDay } from "date-fns"; +import { useTranslations } from "next-intl"; +import { useDisplayDateFormatter } from "@/hooks/use-display-date-formatter"; +import { format, isTomorrow, startOfDay } from "date-fns"; import { MapPin, Users } from "lucide-react"; import { cn } from "@/lib/utils"; import { getEventColor } from "./event-card"; import { getEventDayBounds, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils"; +import { displayNow, isDisplayToday } from "@/lib/timezone"; import { getParticipantCount } from "@/lib/calendar-participants"; import type { CalendarEvent, Calendar } from "@/lib/jmap/types"; @@ -38,7 +40,9 @@ export function CalendarAgendaView({ timeFormat = "24h", }: CalendarAgendaViewProps) { const t = useTranslations("calendar"); - const intlFormatter = useFormatter(); + // Grid days / event dates are display dates (local fields = wall-clock in + // the user's zone); the app-wide formatter would shift them again (#755). + const intlFormatter = useDisplayDateFormatter(); const calendarMap = useMemo(() => { const map = new Map(); @@ -76,9 +80,9 @@ export function CalendarAgendaView({ }); // Always include today's date in the groups so the view has a "Today" anchor - const todayKey = format(new Date(), "yyyy-MM-dd"); + const todayKey = format(displayNow(), "yyyy-MM-dd"); if (!groupMap.has(todayKey)) { - const todayGroup = { date: startOfDay(new Date()), dateKey: todayKey, events: [] as CalendarEvent[] }; + const todayGroup = { date: startOfDay(displayNow()), dateKey: todayKey, events: [] as CalendarEvent[] }; groupMap.set(todayKey, todayGroup); groups.push(todayGroup); } @@ -102,13 +106,13 @@ export function CalendarAgendaView({ useEffect(() => { // Scroll to today when selectedDate changes to today - if (isToday(selectedDate)) { + if (isDisplayToday(selectedDate)) { scrollToToday(); } }, [selectedDate, scrollToToday]); const formatDateHeader = (date: Date): string => { - if (isToday(date)) return t("events.today_header"); + if (isDisplayToday(date)) return t("events.today_header"); if (isTomorrow(date)) return t("events.tomorrow_header"); return intlFormatter.dateTime(date, { weekday: "long", month: "long", day: "numeric" }); }; @@ -123,11 +127,11 @@ export function CalendarAgendaView({ return (
{grouped.map((group) => ( -
+
{formatDateHeader(group.date)} diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index 3a65b4b04..3407d851f 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -54,6 +54,7 @@ import { ResizeHandle } from "@/components/layout/resize-handle"; import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization"; import { buildRecurrenceOverridePatch } from "@/lib/recurrence-overrides"; import { getEventStartDate } from "@/lib/calendar-utils"; +import { displayNow } from "@/lib/timezone"; import { useTaskStore } from "@/stores/task-store"; import { useContactStore } from "@/stores/contact-store"; import { cn } from "@/lib/utils"; @@ -156,7 +157,7 @@ export function CalendarApp({ linkSegments }: CalendarAppProps = {}) { const [defaultModalDate, setDefaultModalDate] = useState(); const [defaultModalEndDate, setDefaultModalEndDate] = useState(); const [defaultModalAllDay, setDefaultModalAllDay] = useState(false); - const [miniMonth, setMiniMonth] = useState(new Date()); + const [miniMonth, setMiniMonth] = useState(() => displayNow()); const [pendingScopeAction, setPendingScopeAction] = useState(null); const [detailEvent, setDetailEvent] = useState(null); const [detailAnchorRect, setDetailAnchorRect] = useState(null); @@ -357,7 +358,7 @@ export function CalendarApp({ linkSegments }: CalendarAppProps = {}) { }; case "agenda": { // Agenda always starts from today at the earliest - const today = startOfDay(new Date()); + const today = startOfDay(displayNow()); const agendaStart = d >= today ? d : today; return { start: format(agendaStart, "yyyy-MM-dd'T'00:00:00"), @@ -420,8 +421,8 @@ export function CalendarApp({ linkSegments }: CalendarAppProps = {}) { }, [normalizedViewMode, selectedDate, setSelectedDate]); const goToToday = useCallback(() => { - setSelectedDate(new Date()); - setMiniMonth(new Date()); + setSelectedDate(displayNow()); + setMiniMonth(displayNow()); }, [setSelectedDate]); // Swipe navigation handlers for mobile @@ -1014,7 +1015,7 @@ export function CalendarApp({ linkSegments }: CalendarAppProps = {}) { const handleSaveNoteFromDetail = useCallback(async (note: string) => { if (!detailEvent || !client) return; - const timestamp = format(new Date(), "yyyy-MM-dd HH:mm"); + const timestamp = format(displayNow(), "yyyy-MM-dd HH:mm"); const separator = `\n\n--- ${timestamp} ---\n`; const newDescription = detailEvent.description ? `${detailEvent.description}${separator}${note}` @@ -1670,7 +1671,7 @@ export function CalendarApp({ linkSegments }: CalendarAppProps = {}) { end.setHours(hour + 1, 0, 0, 0); openCreateModal(d, end); } else { - const now = new Date(); + const now = displayNow(); d.setHours(now.getHours() + 1, 0, 0, 0); openCreateModal(d); } diff --git a/components/calendar/calendar-day-view.tsx b/components/calendar/calendar-day-view.tsx index 943480b3d..eefe370f2 100644 --- a/components/calendar/calendar-day-view.tsx +++ b/components/calendar/calendar-day-view.tsx @@ -1,13 +1,15 @@ "use client"; import { useMemo, useEffect, useRef, useState } from "react"; -import { useTranslations, useFormatter } from "next-intl"; -import { format, isSameDay, isToday, parseISO } from "date-fns"; +import { useTranslations } from "next-intl"; +import { useDisplayDateFormatter } from "@/hooks/use-display-date-formatter"; +import { format, isSameDay, parseISO } from "date-fns"; import { cn } from "@/lib/utils"; import { Check } from "lucide-react"; import { EventCard } from "./event-card"; import { QuickEventInput } from "./quick-event-input"; import { formatSnapTime, getEventDayBounds, getPrimaryCalendarId, isTimedEventFullDayOnDate, layoutOverlappingEvents } from "@/lib/calendar-utils"; +import { displayNow, isDisplayToday } from "@/lib/timezone"; import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types"; import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions"; import type { PendingEventPreview } from "./event-modal"; @@ -49,7 +51,9 @@ export function CalendarDayView({ onToggleTaskComplete, }: CalendarDayViewProps) { const t = useTranslations("calendar"); - const intlFormatter = useFormatter(); + // Grid days / event dates are display dates (local fields = wall-clock in + // the user's zone); the app-wide formatter would shift them again (#755). + const intlFormatter = useDisplayDateFormatter(); const scrollRef = useRef(null); const dayKey = format(selectedDate, "yyyy-MM-dd"); @@ -89,19 +93,20 @@ export function CalendarDayView({ useEffect(() => { if (scrollRef.current) { - const now = new Date(); + const now = displayNow(); scrollRef.current.scrollTop = Math.max(0, (now.getHours() - 1) * HOUR_HEIGHT); } }, []); - const today = isToday(selectedDate); + const today = isDisplayToday(selectedDate); const [nowMinutes, setNowMinutes] = useState(() => { - const now = new Date(); + const now = displayNow(); return now.getHours() * 60 + now.getMinutes(); }); useEffect(() => { const interval = setInterval(() => { - setNowMinutes(new Date().getHours() * 60 + new Date().getMinutes()); + const now = displayNow(); + setNowMinutes(now.getHours() * 60 + now.getMinutes()); }, 60000); return () => clearInterval(interval); }, []); diff --git a/components/calendar/calendar-sidebar-panel.tsx b/components/calendar/calendar-sidebar-panel.tsx index 5529f046c..2f7ed28ab 100644 --- a/components/calendar/calendar-sidebar-panel.tsx +++ b/components/calendar/calendar-sidebar-panel.tsx @@ -16,6 +16,7 @@ import { toast } from "@/stores/toast-store"; import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu"; import { useContextMenu } from "@/hooks/use-context-menu"; import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import { displayNow } from "@/lib/timezone"; /** * Split a per-account calendar list into "owned" (the user's own) and @@ -105,7 +106,7 @@ export function CalendarSidebarPanel({ const pendingTaskCount = useMemo(() => tasks.filter(t => t.progress !== 'completed' && t.progress !== 'cancelled').length, [tasks]); const overdueTaskCount = useMemo(() => { - const now = new Date(); + const now = displayNow(); return tasks.filter(t => t.progress !== 'completed' && t.progress !== 'cancelled' && t.due && new Date(t.due) < now).length; }, [tasks]); diff --git a/components/calendar/calendar-week-view.tsx b/components/calendar/calendar-week-view.tsx index dbfe08cee..4ade2ec67 100644 --- a/components/calendar/calendar-week-view.tsx +++ b/components/calendar/calendar-week-view.tsx @@ -1,15 +1,17 @@ "use client"; import { useMemo, useEffect, useRef, useState } from "react"; -import { useTranslations, useFormatter } from "next-intl"; +import { useTranslations } from "next-intl"; +import { useDisplayDateFormatter } from "@/hooks/use-display-date-formatter"; import { - startOfWeek, addDays, format, isSameDay, isToday, parseISO, + startOfWeek, addDays, format, isSameDay, parseISO, } from "date-fns"; import { cn } from "@/lib/utils"; import { Check } from "lucide-react"; import { EventCard } from "./event-card"; import { QuickEventInput } from "./quick-event-input"; import { buildTimedFullDayWeekSegments, buildWeekSegmentsRaw, formatSnapTime, getEventDayBounds, getPrimaryCalendarId, isTimedEventFullDayOnDate, layoutOverlappingEvents, packWeekSegments } from "@/lib/calendar-utils"; +import { displayNow, isDisplayToday } from "@/lib/timezone"; import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types"; import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions"; import type { PendingEventPreview } from "./event-modal"; @@ -55,7 +57,9 @@ export function CalendarWeekView({ onToggleTaskComplete, }: CalendarWeekViewProps) { const t = useTranslations("calendar"); - const intlFormatter = useFormatter(); + // Grid days / event dates are display dates (local fields = wall-clock in + // the user's zone); the app-wide formatter would shift them again (#755). + const intlFormatter = useDisplayDateFormatter(); const scrollRef = useRef(null); const rootRef = useRef(null); const weekStart = (firstDayOfWeek === 0 ? 0 : firstDayOfWeek === 6 ? 6 : 1) as 0 | 1 | 6; @@ -143,12 +147,12 @@ export function CalendarWeekView({ useEffect(() => { if (scrollRef.current) { - const now = new Date(); + const now = displayNow(); scrollRef.current.scrollTop = Math.max(0, (now.getHours() - 1) * HOUR_HEIGHT); } // On mobile, scroll horizontally to center today's column if (isMobile && rootRef.current) { - const todayIdx = weekDays.findIndex(d => isToday(d)); + const todayIdx = weekDays.findIndex(d => isDisplayToday(d)); if (todayIdx >= 0) { const gutter = 40; const colWidth = (rootRef.current.scrollWidth - gutter) / 7; @@ -160,12 +164,13 @@ export function CalendarWeekView({ }, []); const [nowMinutes, setNowMinutes] = useState(() => { - const now = new Date(); + const now = displayNow(); return now.getHours() * 60 + now.getMinutes(); }); useEffect(() => { const interval = setInterval(() => { - setNowMinutes(new Date().getHours() * 60 + new Date().getMinutes()); + const now = displayNow(); + setNowMinutes(now.getHours() * 60 + now.getMinutes()); }, 60000); return () => clearInterval(interval); }, []); @@ -308,7 +313,7 @@ export function CalendarWeekView({
{weekDays.map((day) => { - const todayCol = isToday(day); + const todayCol = isDisplayToday(day); const selected = isSameDay(day, selectedDate); const fullLabel = intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric", year: "numeric" }); return ( @@ -361,7 +366,7 @@ export function CalendarWeekView({ {weekDays.map((day) => { const key = format(day, "yyyy-MM-dd"); const dayEvents = timedEvents.get(key) || []; - const todayCol = isToday(day); + const todayCol = isDisplayToday(day); const layouted = layoutOverlappingEvents(dayEvents, day); return ( diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index 7069d9185..b2e9a7898 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -10,6 +10,7 @@ import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert, import { RecurrenceEditor, buildRecurrenceSummary, isSimpleRecurrenceRule } from "./recurrence-editor"; import { parseDuration, getEventColor } from "./event-card"; import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils"; +import { displayNow, getEffectiveTimeZone } from "@/lib/timezone"; import { ParticipantInput, type ParticipantInputHandle } from "./participant-input"; import { isOrganizer, @@ -257,11 +258,11 @@ export function EventModal({ if (defaultDate) { const d = new Date(defaultDate); if (defaultEndDate) return d; - const now = new Date(); + const now = displayNow(); d.setHours(now.getHours() + 1, 0, 0, 0); return d; } - const d = new Date(); + const d = displayNow(); d.setHours(d.getHours() + 1, 0, 0, 0); return d; }; @@ -487,7 +488,9 @@ export function EventModal({ duration = buildDuration(start, end); } - const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + // The form's wall-clock fields are in the user's effective zone (the + // grid and prefill are display dates), so label them with that zone. + const timeZone = getEffectiveTimeZone(); const data: Partial = { title: trimmedTitle, @@ -1270,7 +1273,7 @@ export function EventModal({ rule={customRule} eventStart={(() => { const d = new Date(`${startDate}T${allDay ? "00:00" : (startTime || "00:00")}:00`); - return isNaN(d.getTime()) ? new Date() : d; + return isNaN(d.getTime()) ? displayNow() : d; })()} onSave={handleRecurrenceEditorSave} onCancel={handleRecurrenceEditorCancel} diff --git a/components/calendar/mini-calendar.tsx b/components/calendar/mini-calendar.tsx index a51f74208..47421fe7f 100644 --- a/components/calendar/mini-calendar.tsx +++ b/components/calendar/mini-calendar.tsx @@ -9,6 +9,7 @@ import { } from "date-fns"; import { cn } from "@/lib/utils"; import { getEventDayBounds } from "@/lib/calendar-utils"; +import { displayNow } from "@/lib/timezone"; import type { CalendarEvent } from "@/lib/jmap/types"; import { useCalendarLocale } from "@/hooks/use-calendar-locale"; @@ -205,7 +206,7 @@ export function MiniCalendar({ {pickerView === "months" && (
{monthLabelKeys.map((labelKey, i) => { - const isCurrentMonth = i === currentMonth && currentYear === getYear(new Date()); + const isCurrentMonth = i === currentMonth && currentYear === getYear(displayNow()); const isSelected = i === getMonth(selectedDate) && currentYear === getYear(selectedDate); return (
{bodyDoc && ( diff --git a/components/files/file-browser.tsx b/components/files/file-browser.tsx index 10fbd3307..131216b03 100644 --- a/components/files/file-browser.tsx +++ b/components/files/file-browser.tsx @@ -29,6 +29,7 @@ import type { FileResource } from "@/stores/file-store"; import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog"; import type { IJMAPClient } from "@/lib/jmap/client-interface"; import type { FileNodeRights } from "@/lib/jmap/types"; +import { getEffectiveTimeZone } from "@/lib/timezone"; type SortKey = "name" | "size" | "modified"; type SortDir = "asc" | "desc"; @@ -299,6 +300,7 @@ function formatDate(dateString: string): string { if (!dateString) return ""; try { return new Date(dateString).toLocaleDateString(undefined, { + timeZone: getEffectiveTimeZone(), year: "numeric", month: "short", day: "numeric", diff --git a/components/providers/intl-provider.tsx b/components/providers/intl-provider.tsx index 45b8027ea..14f6369b5 100644 --- a/components/providers/intl-provider.tsx +++ b/components/providers/intl-provider.tsx @@ -3,6 +3,8 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { NextIntlClientProvider } from 'next-intl'; import { useLocaleStore } from '@/stores/locale-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { getBrowserTimeZone, resolveTimeZone } from '@/lib/timezone'; import enMessages from '@/locales/en/common.json'; import { getLocaleDirection } from '@/i18n/direction'; import { mergeMessages } from '@/i18n/merge-messages'; @@ -52,7 +54,11 @@ interface IntlProviderProps { export function IntlProvider({ locale: initialLocale, messages: initialMessages, children }: IntlProviderProps) { const currentLocale = useLocaleStore((state) => state.locale); - const [timeZone, setTimeZone] = useState('UTC'); + // Browser zone is detected on mount (SSR has no browser, so it renders UTC + // until then); the user's `timeZone` setting overrides it (#755). + const [browserTimeZone, setBrowserTimeZone] = useState('UTC'); + const timeZoneSetting = useSettingsStore((state) => state.timeZone); + const timeZone = resolveTimeZone(timeZoneSetting, browserTimeZone); // The catalog currently in use. Seeded with the server-provided messages for // the SSR-resolved locale, so the first render needs no async work and @@ -67,16 +73,9 @@ export function IntlProvider({ locale: initialLocale, messages: initialMessages, [initialLocale]: initialMessages, }); - // Detect user's timezone on mount + // Detect the browser's timezone on mount (falls back to UTC internally) useEffect(() => { - try { - const detectedTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; - setTimeZone(detectedTimeZone); - } catch (error) { - // Fallback to UTC if detection fails - console.warn('Failed to detect timezone, using UTC:', error); - setTimeZone('UTC'); - } + setBrowserTimeZone(getBrowserTimeZone()); }, []); // Resolve the active locale from the user's stored choice. Empty or 'auto' diff --git a/components/settings/language-settings.tsx b/components/settings/language-settings.tsx index fc46e523d..4cc49d8b7 100644 --- a/components/settings/language-settings.tsx +++ b/components/settings/language-settings.tsx @@ -1,19 +1,39 @@ "use client"; -import { useMemo } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useTranslations } from 'next-intl'; import { LanguageSwitcher } from '@/components/ui/language-switcher'; import { useLocaleStore } from '@/stores/locale-store'; import { useSettingsStore } from '@/stores/settings-store'; import type { DateFormat, DateLocale, TimeFormat, FirstDayOfWeek } from '@/stores/settings-store'; import { formatDate } from '@/lib/utils'; +import { AUTO_TIME_ZONE, getBrowserTimeZone, getEffectiveTimeZone, listTimeZones } from '@/lib/timezone'; import { SettingsSection, SettingItem, Select, RadioGroup } from './settings-section'; export function LanguageSettings() { const t = useTranslations('settings.language_region'); const tDays = useTranslations('calendar.days'); - const { dateFormat, dateLocale, timeFormat, firstDayOfWeek, updateSetting } = useSettingsStore(); + const { dateFormat, dateLocale, timeFormat, firstDayOfWeek, timeZone, updateSetting } = useSettingsStore(); + + // Browser zone + zone list are read after mount: SSR has no browser, and a + // server/client mismatch in the option list would trip hydration. + const [browserTimeZone, setBrowserTimeZone] = useState('UTC'); + const [timeZones, setTimeZones] = useState([]); + useEffect(() => { + setBrowserTimeZone(getBrowserTimeZone()); + setTimeZones(listTimeZones()); + }, []); + + const timeZoneOptions = useMemo(() => { + const options = [{ value: AUTO_TIME_ZONE, label: t('time_zone.auto', { zone: browserTimeZone }) }]; + // A zone synced from another browser (newer tz database) may be missing + // from this runtime's list; keep it selectable rather than snapping the + // updateSetting('timeZone', value)} + options={timeZoneOptions} + className="max-w-[16rem]" + /> + {timeZonePreview && ( +
+ {t('time_zone.preview_now')} + {timeZonePreview} +
+ )} +
+ +