feat: add custom time zone setting overriding browser detection #755

This commit is contained in:
Linus Rath
2026-08-25 19:18:16 +02:00
parent 46ccd2267d
commit 9f32fc879b
47 changed files with 776 additions and 76 deletions
+13 -9
View File
@@ -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<string, Calendar>();
@@ -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 (
<div className="flex-1 overflow-y-auto" ref={scrollContainerRef}>
{grouped.map((group) => (
<div key={group.dateKey} ref={isToday(group.date) ? todayRef : undefined}>
<div key={group.dateKey} ref={isDisplayToday(group.date) ? todayRef : undefined}>
<div className="sticky top-0 bg-muted/80 backdrop-blur-sm px-4 py-2 border-b border-border">
<span className={cn(
"text-sm font-medium",
isToday(group.date) && "text-primary"
isDisplayToday(group.date) && "text-primary"
)}>
{formatDateHeader(group.date)}
</span>
+7 -6
View File
@@ -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<Date | undefined>();
const [defaultModalEndDate, setDefaultModalEndDate] = useState<Date | undefined>();
const [defaultModalAllDay, setDefaultModalAllDay] = useState(false);
const [miniMonth, setMiniMonth] = useState(new Date());
const [miniMonth, setMiniMonth] = useState(() => displayNow());
const [pendingScopeAction, setPendingScopeAction] = useState<PendingScopeAction | null>(null);
const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null);
const [detailAnchorRect, setDetailAnchorRect] = useState<DOMRect | null>(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);
}
+12 -7
View File
@@ -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<HTMLDivElement>(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);
}, []);
@@ -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]);
+14 -9
View File
@@ -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<HTMLDivElement>(null);
const rootRef = useRef<HTMLDivElement>(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({
<div className={cn("flex-shrink-0", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")} />
<div className="flex-1 border-s border-border grid grid-cols-7">
{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 (
+7 -4
View File
@@ -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<CalendarEvent> = {
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}
+3 -2
View File
@@ -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" && (
<div className="grid grid-cols-3 gap-1 py-1">
{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 (
<button
@@ -229,7 +230,7 @@ export function MiniCalendar({
<div className="grid grid-cols-3 gap-1 py-1">
{years.map((year) => {
const inDecade = year >= decadeStart && year <= decadeStart + 9;
const isCurrentYear = year === getYear(new Date());
const isCurrentYear = year === getYear(displayNow());
const isSelected = year === getYear(selectedDate);
return (
<button
+5 -4
View File
@@ -2,7 +2,8 @@
import { useMemo, useCallback, useState } from "react";
import { useTranslations } from "next-intl";
import { format, parseISO, isPast, isToday, isTomorrow } from "date-fns";
import { format, parseISO, isBefore, isTomorrow } from "date-fns";
import { displayNow, isDisplayToday } from "@/lib/timezone";
import { Check, Flag, CalendarDays, ListTodo, Plus } from "lucide-react";
import { cn } from "@/lib/utils";
import type { CalendarTask, Calendar } from "@/lib/jmap/types";
@@ -30,9 +31,9 @@ function getTaskPriorityIcon(priority: number) {
function getDueDateLabel(due: string, showWithoutTime: boolean, t: ReturnType<typeof useTranslations>, timeFormat: string): { label: string; className: string } {
const dueDate = parseISO(due);
const overdue = isPast(dueDate) && !isToday(dueDate);
const overdue = isBefore(dueDate, displayNow()) && !isDisplayToday(dueDate);
if (isToday(dueDate)) {
if (isDisplayToday(dueDate)) {
return {
label: t("tasks.due_today"),
className: "text-blue-600 dark:text-blue-400",
@@ -96,7 +97,7 @@ export function TaskListView({
case "overdue":
result = result.filter(task => {
if (!task.due || task.progress === "completed" || task.progress === "cancelled") return false;
return isPast(parseISO(task.due)) && !isToday(parseISO(task.due));
return isBefore(parseISO(task.due), displayNow()) && !isDisplayToday(parseISO(task.due));
});
break;
}
+2 -1
View File
@@ -3,6 +3,7 @@
import { useTranslations } from "next-intl";
import { Paperclip, Download } from "lucide-react";
import { sanitizeEmailHtmlForIframe } from "@/lib/email-sanitization";
import { getEffectiveTimeZone } from "@/lib/timezone";
// A parsed message/rfc822 (.eml), as produced by postal-mime. Only the fields
// this preview renders are typed.
@@ -74,7 +75,7 @@ export function EmlPreview({ message }: { message: ParsedEml }) {
<div><span className="font-medium text-foreground">{t("to")}: </span><bdi>{message.to.map(formatAddress).join(", ")}</bdi></div>
)}
{message.date && (
<div><span className="font-medium text-foreground">{t("date")}: </span>{new Date(message.date).toLocaleString()}</div>
<div><span className="font-medium text-foreground">{t("date")}: </span>{new Date(message.date).toLocaleString(undefined, { timeZone: getEffectiveTimeZone() })}</div>
)}
</div>
{bodyDoc && (
+2
View File
@@ -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",
+9 -10
View File
@@ -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<string>('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<string>('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'
+57 -4
View File
@@ -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<string[]>([]);
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
// <select> to the first entry.
const zones = timeZone !== AUTO_TIME_ZONE && !timeZones.includes(timeZone) ? [...timeZones, timeZone].sort() : timeZones;
for (const zone of zones) options.push({ value: zone, label: zone.replace(/_/g, ' ') });
return options;
}, [t, browserTimeZone, timeZones, timeZone]);
// Subscribe to locale changes so the preview re-renders on language switch
// (formatDate reads it via getState() and would otherwise stay stale).
@@ -23,7 +43,7 @@ export function LanguageSettings() {
// Build sample timestamps for each bucket so users see what their pick
// will look like in practice. Use offsets relative to "now" so the
// bucketing is stable even though the wall-clock keeps moving.
void locale; void dateFormat; void dateLocale; void timeFormat;
void locale; void dateFormat; void dateLocale; void timeFormat; void timeZone;
const now = new Date();
const today = new Date(now);
today.setHours(15, 31, 0, 0);
@@ -38,7 +58,23 @@ export function LanguageSettings() {
thisWeek: formatDate(thisWeek),
older: formatDate(older),
};
}, [locale, dateFormat, dateLocale, timeFormat]);
}, [locale, dateFormat, dateLocale, timeFormat, timeZone]);
// Current wall-clock in the effective zone so a pick can be sanity-checked.
const timeZonePreview = useMemo(() => {
void timeZone; void timeFormat; void locale;
try {
return new Date().toLocaleTimeString(locale && locale !== 'auto' ? locale : undefined, {
timeZone: getEffectiveTimeZone(),
hour: '2-digit',
minute: '2-digit',
hour12: timeFormat === '12h',
timeZoneName: 'short',
});
} catch {
return '';
}
}, [timeZone, timeFormat, locale]);
return (
<SettingsSection title={t('title')} description={t('description')}>
@@ -98,6 +134,23 @@ export function LanguageSettings() {
/>
</SettingItem>
<SettingItem label={t('time_zone.label')} description={t('time_zone.description')}>
<div className="flex flex-col items-end gap-2">
<Select
value={timeZone}
onChange={(value) => updateSetting('timeZone', value)}
options={timeZoneOptions}
className="max-w-[16rem]"
/>
{timeZonePreview && (
<div className="text-xs text-muted-foreground text-end font-mono">
<span className="opacity-70">{t('time_zone.preview_now')} </span>
<span className="text-foreground/90">{timeZonePreview}</span>
</div>
)}
</div>
</SettingItem>
<SettingItem label={t('first_day.label')} description={t('first_day.description')}>
<Select
value={firstDayOfWeek.toString()}
+3 -3
View File
@@ -22,8 +22,8 @@ import {
eachDayOfInterval,
isSameDay,
isSameMonth,
isToday,
} from "date-fns";
import { displayNow, isDisplayToday } from "@/lib/timezone";
/**
* Unified calendar-locale hook.
@@ -74,11 +74,11 @@ export function useCalendarLocale() {
/** Is the given date "today" in the active calendar system? */
const checkIsToday = (date: Date): boolean => {
if (isJalali) {
const now = toJalali(new Date());
const now = toJalali(displayNow());
const target = toJalali(date);
return now.jy === target.jy && now.jm === target.jm && now.jd === target.jd;
}
return isToday(date);
return isDisplayToday(date);
};
/** Does the date belong to the same month as the reference date? */
+26
View File
@@ -0,0 +1,26 @@
"use client";
import { useMemo } from "react";
import { useFormatter, type DateTimeFormatOptions } from "next-intl";
import { getBrowserTimeZone } from "@/lib/timezone";
/**
* next-intl formatter for *display dates* - calendar grid days and the
* results of getEventStartDate() / getEventEndDate(), whose local fields
* already read as the wall-clock in the user's effective time zone.
*
* The app-wide formatter renders every date in the user's time zone override
* (#755), which would shift a display date a second time and mislabel day
* headers and hour gutters. Pinning the runtime's own zone makes Intl render
* the Date's local fields unchanged - exactly what a display date encodes.
*/
export function useDisplayDateFormatter() {
const format = useFormatter();
return useMemo(() => {
const timeZone = getBrowserTimeZone();
return {
dateTime: (date: Date | number, options?: DateTimeFormatOptions) =>
format.dateTime(date, { ...options, timeZone }),
};
}, [format]);
}
+6 -3
View File
@@ -5,6 +5,7 @@ import { useCalendarStore } from "@/stores/calendar-store";
import { toast } from "@/stores/toast-store";
import { debug } from "@/lib/debug";
import { formatIsoInTimeZone } from "@/lib/calendar-utils";
import { fromDisplayDate, getEffectiveTimeZone } from "@/lib/timezone";
import type { Calendar } from "@/lib/jmap/types";
interface DragCreateState {
@@ -302,7 +303,7 @@ export function useTimeGridInteractions({
const startDate = new Date(quickCreate.day);
startDate.setHours(quickCreate.hour, 0, 0, 0);
const startStr = format(startDate, "yyyy-MM-dd'T'HH:mm:ss");
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const timeZone = getEffectiveTimeZone();
const defaultCal = calendars.find(c => c.isDefault) || calendars[0];
const created = await useCalendarStore.getState().createEvent(client, {
title,
@@ -367,8 +368,10 @@ export function useTimeGridInteractions({
// Emit `start` as a floating wall-clock in the event's own timeZone.
// If we used browser-local, the server would reinterpret it in event.timeZone
// and shift the event by the offset between the two zones.
const eventTimeZone = event?.timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone;
const newStartISO = formatIsoInTimeZone(newStart, eventTimeZone);
const eventTimeZone = event?.timeZone || getEffectiveTimeZone();
// `newStart` is a display date (grid space); turn it back into the real
// instant before expressing it in the event's zone (#755).
const newStartISO = formatIsoInTimeZone(fromDisplayDate(newStart), eventTimeZone);
if (newStartISO === data.originalStart) return;
const client = useAuthStore.getState().client;
if (!client) {
+175
View File
@@ -0,0 +1,175 @@
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
import {
AUTO_TIME_ZONE,
displayNow,
fromDisplayDate,
fromZonedDisplayDate,
getBrowserTimeZone,
getTimeZoneOffsetMs,
getWallClock,
isDisplayToday,
isValidTimeZone,
listTimeZones,
resolveTimeZone,
toDisplayDate,
toZonedDisplayDate,
} from '../timezone';
import { formatDate, formatDateTime } from '../utils';
import { useSettingsStore } from '@/stores/settings-store';
// Pin the process zone so "browser" detection is deterministic - this is the
// LibreWolf / resistFingerprinting scenario from #755: the runtime reports
// UTC, the user actually lives somewhere else.
const ORIGINAL_TZ = process.env.TZ;
beforeAll(() => {
process.env.TZ = 'UTC';
});
afterAll(() => {
if (ORIGINAL_TZ === undefined) delete process.env.TZ;
else process.env.TZ = ORIGINAL_TZ;
});
afterEach(() => {
useSettingsStore.setState({ timeZone: AUTO_TIME_ZONE, dateFormat: 'smart', timeFormat: '24h', dateLocale: 'auto' });
vi.useRealTimers();
});
const HOUR = 3600_000;
describe('resolveTimeZone', () => {
it('follows the browser for auto / empty / unknown values', () => {
expect(resolveTimeZone(AUTO_TIME_ZONE, 'Europe/Berlin')).toBe('Europe/Berlin');
expect(resolveTimeZone('', 'Europe/Berlin')).toBe('Europe/Berlin');
expect(resolveTimeZone(undefined, 'Europe/Berlin')).toBe('Europe/Berlin');
expect(resolveTimeZone('Mars/Olympus_Mons', 'Europe/Berlin')).toBe('Europe/Berlin');
});
it('returns a valid explicit zone unchanged', () => {
expect(resolveTimeZone('Asia/Tokyo', 'Europe/Berlin')).toBe('Asia/Tokyo');
});
it('validates zone ids against Intl', () => {
expect(isValidTimeZone('America/New_York')).toBe(true);
expect(isValidTimeZone('Not/A_Zone')).toBe(false);
expect(isValidTimeZone('')).toBe(false);
expect(isValidTimeZone(42)).toBe(false);
});
it('lists zones including the common ones', () => {
const zones = listTimeZones();
expect(zones).toContain('Europe/Berlin');
expect(zones[0]).toBe('UTC');
expect(zones.filter((z) => z === 'UTC')).toHaveLength(1);
const rest = zones.slice(1);
expect([...rest].sort()).toEqual(rest);
});
});
describe('wall-clock helpers', () => {
const noon = new Date('2026-07-01T12:00:00Z');
it('reads the wall-clock of an instant in a zone', () => {
expect(getWallClock(noon, 'Asia/Tokyo')).toEqual({ year: 2026, month: 7, day: 1, hour: 21, minute: 0, second: 0 });
expect(getWallClock(noon, 'America/Los_Angeles')).toEqual({ year: 2026, month: 7, day: 1, hour: 5, minute: 0, second: 0 });
});
it('computes DST-aware offsets', () => {
expect(getTimeZoneOffsetMs(noon, 'Asia/Tokyo')).toBe(9 * HOUR);
expect(getTimeZoneOffsetMs(noon, 'Europe/Berlin')).toBe(2 * HOUR);
expect(getTimeZoneOffsetMs(new Date('2026-01-15T12:00:00Z'), 'Europe/Berlin')).toBe(1 * HOUR);
expect(getTimeZoneOffsetMs(noon, 'UTC')).toBe(0);
});
it('shifts an instant so local getters read as the zone wall-clock', () => {
const berlin = toZonedDisplayDate(noon, 'Europe/Berlin');
expect(berlin.getHours()).toBe(14);
expect(berlin.getDate()).toBe(1);
// Crosses midnight: 23:30Z on the 1st is 01:30 on the 2nd in Berlin.
const late = toZonedDisplayDate(new Date('2026-07-01T23:30:00Z'), 'Europe/Berlin');
expect(late.getDate()).toBe(2);
expect(late.getHours()).toBe(1);
expect(late.getMinutes()).toBe(30);
});
it('round-trips through fromZonedDisplayDate, including across a DST switch', () => {
const instants = [
'2026-07-01T12:00:00Z',
'2026-07-01T23:30:00Z',
'2026-01-15T00:15:00Z',
'2026-03-29T00:59:00Z', // Berlin: last minute of CET before the spring-forward
'2026-03-29T01:00:00Z', // Berlin: 03:00 CEST, right after the gap
'2026-10-25T01:30:00Z', // Berlin: second 02:30 of the fall-back night
];
for (const iso of instants) {
for (const zone of ['Europe/Berlin', 'America/New_York', 'Asia/Kolkata', 'Pacific/Auckland', 'UTC']) {
const instant = new Date(iso);
const shifted = toZonedDisplayDate(instant, zone);
expect(fromZonedDisplayDate(shifted, zone).getTime(), `${iso} in ${zone}`).toBe(instant.getTime());
}
}
});
it('keeps invalid dates invalid instead of throwing', () => {
const bad = new Date('nope');
expect(isNaN(toZonedDisplayDate(bad, 'Europe/Berlin').getTime())).toBe(true);
expect(isNaN(fromZonedDisplayDate(bad, 'Europe/Berlin').getTime())).toBe(true);
});
});
describe('store-aware display dates', () => {
it('are the identity while the setting is auto', () => {
expect(getBrowserTimeZone()).toBe('UTC');
const d = new Date('2026-07-01T12:00:00Z');
expect(toDisplayDate(d)).toBe(d);
expect(fromDisplayDate(d)).toBe(d);
});
it('shift by the override zone and invert cleanly', () => {
useSettingsStore.setState({ timeZone: 'Asia/Tokyo' });
const d = new Date('2026-07-01T12:00:00Z');
const display = toDisplayDate(d);
expect(display.getHours()).toBe(21);
expect(display.getTime() - d.getTime()).toBe(9 * HOUR);
expect(fromDisplayDate(display).getTime()).toBe(d.getTime());
});
it('report "today" from the override zone, not the browser zone', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-07-01T16:00:00Z')); // 01:00 on July 2 in Tokyo
useSettingsStore.setState({ timeZone: 'Asia/Tokyo' });
const now = displayNow();
expect(now.getDate()).toBe(2);
expect(now.getHours()).toBe(1);
expect(isDisplayToday(new Date(2026, 6, 2, 9, 0))).toBe(true);
expect(isDisplayToday(new Date(2026, 6, 1, 23, 0))).toBe(false);
});
it('fall back to the browser zone for an unknown stored zone', () => {
useSettingsStore.setState({ timeZone: 'Mars/Olympus_Mons' });
const d = new Date('2026-07-01T12:00:00Z');
expect(toDisplayDate(d)).toBe(d);
});
});
describe('mail timestamp formatting honours the override', () => {
it('formatDate buckets "today" in the override zone', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-07-01T16:00:00Z')); // 01:00 July 2 in Tokyo
const received = new Date('2026-07-01T14:00:00Z'); // 23:00 July 1 in Tokyo
// Browser zone (UTC): same day -> time only.
expect(formatDate(received)).toBe('14:00');
// Tokyo: yesterday -> short weekday + time, in Tokyo wall-clock.
useSettingsStore.setState({ timeZone: 'Asia/Tokyo' });
expect(formatDate(received)).toBe('Wed 23:00');
});
it('formatDate full and formatDateTime render the override wall-clock', () => {
useSettingsStore.setState({ timeZone: 'Asia/Tokyo', dateFormat: 'full' });
const received = new Date('2026-07-01T14:00:00Z');
expect(formatDate(received)).toContain('23:00');
expect(formatDateTime(received, '24h', { year: 'numeric', month: 'short', day: 'numeric' })).toContain('23:00');
});
});
+11 -2
View File
@@ -1,6 +1,7 @@
import { addDays, differenceInCalendarDays, parseISO, startOfDay, subMilliseconds } from "date-fns";
import { parseDuration } from "@/components/calendar/event-card";
import type { CalendarEvent } from "@/lib/jmap/types";
import { toDisplayDate } from "@/lib/timezone";
export interface CalendarWeekSegment {
event: CalendarEvent;
@@ -21,6 +22,13 @@ export interface TimedEventLayout {
continuesAfter: boolean;
}
/**
* Start of an event as a *display date*: a Date whose local getters read as
* the wall-clock in the user's effective time zone, which is what the grid
* math (getHours(), startOfDay, ...) and every view render. Identical to the
* real instant unless a time zone override is active (#755); convert back
* with `fromDisplayDate` before turning grid positions into instants.
*/
export function getEventStartDate(
event: Pick<CalendarEvent, 'start' | 'utcStart' | 'showWithoutTime'>,
): Date {
@@ -29,7 +37,7 @@ export function getEventStartDate(
// Invalid Date that crashed downstream format() calls (#316).
if (!event.showWithoutTime && event.utcStart) {
const utc = parseISO(event.utcStart);
if (!isNaN(utc.getTime())) return utc;
if (!isNaN(utc.getTime())) return toDisplayDate(utc);
}
return parseISO(event.start);
}
@@ -60,10 +68,11 @@ export function packWeekSegments(rawSegments: CalendarWeekSegment[]): CalendarWe
});
}
/** End of an event as a display date - see `getEventStartDate`. */
export function getEventEndDate(event: CalendarEvent): Date {
if (!event.showWithoutTime && event.utcEnd) {
const utc = parseISO(event.utcEnd);
if (!isNaN(utc.getTime())) return utc;
if (!isNaN(utc.getTime())) return toDisplayDate(utc);
}
const start = getEventStartDate(event);
+4 -2
View File
@@ -12,6 +12,7 @@ import { findTasksOnlyCalendarIds, isTaskLikeObject, type ScannedCalendarObject
import { DEFAULT_CALENDAR_COMPONENTS, mkCalendarCollection, newCalendarCollectionName } from "@/lib/webdav/calendar-collection";
import { sanitizeDisplayName, splitMailbox } from "@/lib/rfc5322-mailbox";
import { decodeFileNodeName } from "./filenode-name";
import { getEffectiveTimeZone } from "@/lib/timezone";
// Names of nodes created over WebDAV come back percent-encoded from
// FileNode/get (see decodeFileNodeName / #869). Normalize at the boundary so
@@ -351,7 +352,8 @@ const CALENDAR_TASK_PROPERTIES = [
] as const;
/**
* IANA time zone of the browser, sent as the `timeZone` argument on
* IANA time zone of the user - their `timeZone` setting when set (#755),
* otherwise the browser's - sent as the `timeZone` argument on
* CalendarEvent/query and CalendarEvent/get. Stalwart interprets the
* LocalDateTime `after`/`before` filter values and computes utcStart/utcEnd
* for floating events in this zone, defaulting to UTC when absent - which
@@ -360,7 +362,7 @@ const CALENDAR_TASK_PROPERTIES = [
*/
function getUserTimeZone(): string | undefined {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || undefined;
return getEffectiveTimeZone() || undefined;
} catch {
return undefined;
}
+232
View File
@@ -0,0 +1,232 @@
import { isSameDay } from "date-fns";
import { useSettingsStore } from "@/stores/settings-store";
/**
* User-selectable display time zone (#755).
*
* Privacy-hardened browsers (LibreWolf, Tor Browser, resistFingerprinting)
* report UTC to every page, so `Intl.DateTimeFormat().resolvedOptions()`
* cannot be trusted as "the user's zone". The `timeZone` setting lets the
* user pin an IANA zone that overrides browser detection everywhere we turn
* an instant into a wall-clock time: mail timestamps, the calendar grid,
* new-event zones and the `timeZone` argument on JMAP calendar queries.
*
* Two families of helpers live here:
*
* - `getEffectiveTimeZone()` for code that formats a real instant with Intl
* (`toLocaleString(..., { timeZone })`). Nothing else changes.
* - `toDisplayDate()` / `fromDisplayDate()` for the calendar, whose grid math
* runs on the *local* getters of `Date` (`getHours()`, date-fns
* `startOfDay`, ...). Rather than teaching every view about zones, an
* instant is shifted once at the boundary into a "display date" whose
* local fields read as the wall-clock in the effective zone, and shifted
* back when the user's grid interaction has to become a real instant.
* Both are identity functions unless an override differing from the
* browser zone is active, so existing behaviour is untouched by default.
*/
/** Sentinel for "follow the browser" - the default. */
export const AUTO_TIME_ZONE = "auto";
/** The zone the browser reports; `UTC` when detection fails (SSR, old engines). */
export function getBrowserTimeZone(): string {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
} catch {
return "UTC";
}
}
const validityCache = new Map<string, boolean>();
/** True when `Intl` accepts `tz` as a time zone identifier. */
export function isValidTimeZone(tz: unknown): tz is string {
if (typeof tz !== "string" || !tz) return false;
let valid = validityCache.get(tz);
if (valid === undefined) {
try {
new Intl.DateTimeFormat("en-US", { timeZone: tz });
valid = true;
} catch {
valid = false;
}
validityCache.set(tz, valid);
}
return valid;
}
/**
* Resolve a stored `timeZone` setting to a concrete IANA zone. `auto`, empty
* or unknown values (a zone synced from a browser with a newer tz database,
* a hand-edited import) fall back to the browser zone instead of throwing
* later inside Intl.
*/
export function resolveTimeZone(setting: string | null | undefined, browserTimeZone = getBrowserTimeZone()): string {
if (!setting || setting === AUTO_TIME_ZONE) return browserTimeZone;
return isValidTimeZone(setting) ? setting : browserTimeZone;
}
/** The zone all instants should be displayed in: the user's override, else the browser's. */
export function getEffectiveTimeZone(): string {
return resolveTimeZone(useSettingsStore.getState().timeZone);
}
/** True when the user's override is active and differs from the browser zone. */
export function isTimeZoneOverridden(): boolean {
return getEffectiveTimeZone() !== getBrowserTimeZone();
}
export interface WallClock {
year: number;
/** 1-12 */
month: number;
day: number;
hour: number;
minute: number;
second: number;
}
// Constructing Intl.DateTimeFormat is expensive (ICU data lookup); cache one
// formatter per zone - the calendar calls into this for every event.
const wallClockFormatterCache = new Map<string, Intl.DateTimeFormat>();
function getWallClockFormatter(timeZone: string): Intl.DateTimeFormat {
let formatter = wallClockFormatterCache.get(timeZone);
if (!formatter) {
formatter = new Intl.DateTimeFormat("en-US", {
timeZone,
hourCycle: "h23",
year: "numeric",
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
second: "numeric",
});
wallClockFormatterCache.set(timeZone, formatter);
}
return formatter;
}
/** Wall-clock fields of `date` as seen in `timeZone`. */
export function getWallClock(date: Date, timeZone: string): WallClock {
const parts = getWallClockFormatter(timeZone).formatToParts(date);
const map: Record<string, number> = {};
for (const part of parts) {
if (part.type !== "literal") map[part.type] = Number(part.value);
}
return {
year: map.year,
month: map.month,
day: map.day,
// Some engines still emit "24" at midnight despite hourCycle h23.
hour: map.hour === 24 ? 0 : map.hour,
minute: map.minute,
second: map.second,
};
}
/** UTC offset of `timeZone` at the given instant, in milliseconds (east = positive). */
export function getTimeZoneOffsetMs(date: Date, timeZone: string): number {
const w = getWallClock(date, timeZone);
const asUtc = Date.UTC(w.year, w.month - 1, w.day, w.hour, w.minute, w.second);
// Intl drops sub-second precision; compare against the whole-second instant.
return asUtc - Math.floor(date.getTime() / 1000) * 1000;
}
/** Build a Date whose *local* fields equal the given wall-clock (years < 100 included). */
function localDateFromWallClock(w: WallClock, milliseconds: number): Date {
const d = new Date(0);
d.setFullYear(w.year, w.month - 1, w.day);
d.setHours(w.hour, w.minute, w.second, milliseconds);
return d;
}
/**
* Shift an instant into a Date whose local getters read as its wall-clock in
* `timeZone` (date-fns-tz's `toZonedTime`). The result is NOT the same
* instant unless the zones agree - only use it for local-field arithmetic
* and rendering.
*/
export function toZonedDisplayDate(date: Date, timeZone: string): Date {
if (isNaN(date.getTime())) return date;
return localDateFromWallClock(getWallClock(date, timeZone), date.getMilliseconds());
}
/**
* Interpret the local fields of `wall` as a wall-clock in `timeZone` and
* return the real instant (date-fns-tz's `fromZonedTime`). Two offset
* lookups handle wall-clocks next to a DST transition.
*/
export function fromZonedDisplayDate(wall: Date, timeZone: string): Date {
if (isNaN(wall.getTime())) return wall;
const asUtc = Date.UTC(
wall.getFullYear(), wall.getMonth(), wall.getDate(),
wall.getHours(), wall.getMinutes(), wall.getSeconds(), wall.getMilliseconds(),
);
const guess = asUtc - getTimeZoneOffsetMs(new Date(asUtc), timeZone);
return new Date(asUtc - getTimeZoneOffsetMs(new Date(guess), timeZone));
}
/**
* Instant -> display date for the calendar grid. Identity unless the user's
* time zone override differs from the browser zone.
*/
export function toDisplayDate(date: Date): Date {
const timeZone = getEffectiveTimeZone();
if (timeZone === getBrowserTimeZone()) return date;
return toZonedDisplayDate(date, timeZone);
}
/** Display date (from the calendar grid) -> real instant. Inverse of `toDisplayDate`. */
export function fromDisplayDate(date: Date): Date {
const timeZone = getEffectiveTimeZone();
if (timeZone === getBrowserTimeZone()) return date;
return fromZonedDisplayDate(date, timeZone);
}
/** "Now" as a display date - what the clock on the wall in the effective zone reads. */
export function displayNow(): Date {
return toDisplayDate(new Date());
}
/** Is the display date `date` on today's calendar day in the effective zone? */
export function isDisplayToday(date: Date): boolean {
return isSameDay(date, displayNow());
}
// A compact fallback for engines without Intl.supportedValuesOf (Safari < 15.4).
// One representative zone per UTC offset band plus the common capitals.
const FALLBACK_TIME_ZONES = [
"UTC",
"Pacific/Honolulu", "America/Anchorage", "America/Los_Angeles", "America/Denver",
"America/Phoenix", "America/Chicago", "America/Mexico_City", "America/New_York",
"America/Toronto", "America/Bogota", "America/Halifax", "America/Sao_Paulo",
"America/Argentina/Buenos_Aires", "Atlantic/Azores", "Europe/London", "Europe/Lisbon",
"Europe/Dublin", "Europe/Paris", "Europe/Berlin", "Europe/Madrid", "Europe/Rome",
"Europe/Amsterdam", "Europe/Zurich", "Europe/Vienna", "Europe/Prague", "Europe/Warsaw",
"Europe/Stockholm", "Europe/Athens", "Europe/Helsinki", "Europe/Kyiv", "Europe/Istanbul",
"Europe/Moscow", "Asia/Jerusalem", "Asia/Dubai", "Asia/Tehran", "Asia/Karachi",
"Asia/Kolkata", "Asia/Dhaka", "Asia/Bangkok", "Asia/Jakarta", "Asia/Shanghai",
"Asia/Hong_Kong", "Asia/Singapore", "Asia/Taipei", "Asia/Seoul", "Asia/Tokyo",
"Australia/Perth", "Australia/Adelaide", "Australia/Sydney", "Pacific/Auckland",
"Africa/Cairo", "Africa/Johannesburg", "Africa/Lagos", "Africa/Nairobi",
];
/**
* All IANA zone identifiers the runtime knows (a short fallback list on
* engines without Intl.supportedValuesOf), sorted with plain `UTC` first -
* ICU's list omits it, and it is exactly what a privacy-conscious user may
* want to pin.
*/
export function listTimeZones(): string[] {
let zones: string[] = FALLBACK_TIME_ZONES;
try {
const intl = Intl as unknown as { supportedValuesOf?: (key: string) => string[] };
const supported = intl.supportedValuesOf?.("timeZone");
if (supported && supported.length > 0) zones = supported;
} catch {
// keep the static list
}
return ["UTC", ...zones.filter((zone) => zone !== "UTC").sort()];
}
+21 -6
View File
@@ -6,6 +6,7 @@ import { debug } from "./debug";
import { getEffectiveLocale } from '@/i18n/detect-locale';
import { useSettingsStore } from "@/stores/settings-store";
import type { DateLocale } from "@/stores/settings-store";
import { getEffectiveTimeZone, getWallClock } from "./timezone";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
@@ -87,6 +88,11 @@ function resolveDateLocale<T extends string | undefined>(
*
* The locale (from the language picker), the region override and the 12h/24h
* preference are all read via `getState()` so this stays SSR-safe.
*
* All output is rendered in the user's effective time zone (the `timeZone`
* setting when set, else the browser's), including the today / this-week
* bucketing - a browser pinned to UTC for anti-fingerprinting must not
* roll a 00:30 local mail into "yesterday" (#755).
*/
export function formatDate(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
@@ -102,6 +108,7 @@ export function formatDate(date: Date | string): string {
// (defaults to `auto` = the UI language, preserving prior behaviour). (#456)
const numericLocale = resolveDateLocale(dateLocale, uiLocale);
const hour12 = timeFormat === "12h";
const timeZone = getEffectiveTimeZone();
if (dateFormat === "relative") {
const diff = now.getTime() - d.getTime();
@@ -116,14 +123,16 @@ export function formatDate(date: Date | string): string {
if (hours < 24) return rtf.format(-hours, "hour");
if (days < 7) return rtf.format(-days, "day");
return d.toLocaleDateString(uiLocale, {
timeZone,
month: "short",
day: "numeric",
year: d.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
year: getWallClock(d, timeZone).year !== getWallClock(now, timeZone).year ? "numeric" : undefined,
});
}
if (dateFormat === "full") {
return d.toLocaleString(numericLocale, {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
@@ -135,15 +144,19 @@ export function formatDate(date: Date | string): string {
// 'smart' (default)
const timeStr = d.toLocaleTimeString(uiLocale, {
timeZone,
hour: "2-digit",
minute: "2-digit",
hour12,
});
// Calendar-day comparison in the display zone, not the browser zone.
const dWall = getWallClock(d, timeZone);
const nowWall = getWallClock(now, timeZone);
const isSameDay =
d.getFullYear() === now.getFullYear() &&
d.getMonth() === now.getMonth() &&
d.getDate() === now.getDate();
dWall.year === nowWall.year &&
dWall.month === nowWall.month &&
dWall.day === nowWall.day;
if (isSameDay) return timeStr;
const daysAgo = Math.floor((now.getTime() - d.getTime()) / 86400000);
@@ -151,12 +164,13 @@ export function formatDate(date: Date | string): string {
// German Intl outputs "Fr." with a trailing dot for `weekday: 'short'`;
// strip it so the result reads cleanly next to the time.
const weekday = d
.toLocaleDateString(uiLocale, { weekday: "short" })
.toLocaleDateString(uiLocale, { timeZone, weekday: "short" })
.replace(/\.$/, "");
return `${weekday} ${timeStr}`;
}
return d.toLocaleDateString(numericLocale, {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
@@ -165,6 +179,7 @@ export function formatDate(date: Date | string): string {
/**
* Format a date/time string respecting the user's 12h/24h time format preference.
* Rendered in the effective time zone (user override, else browser) - #755.
*/
export function formatDateTime(
date: Date | string,
@@ -187,7 +202,7 @@ export function formatDateTime(
const { dateLocale } = useSettingsStore.getState();
const effectiveLocale = resolveDateLocale(dateLocale, undefined);
const localeOptions: Intl.DateTimeFormatOptions = {};
const localeOptions: Intl.DateTimeFormatOptions = { timeZone: getEffectiveTimeZone() };
if (options?.weekday) localeOptions.weekday = options.weekday;
if (options?.year) localeOptions.year = options.year;
if (options?.month) localeOptions.month = options.month;
+6
View File
@@ -1179,6 +1179,12 @@
"description": "ابدأ الأسبوع بالأحد أو الاثنين",
"sunday": "الأحد",
"monday": "الاثنين"
},
"time_zone": {
"label": "Time zone",
"description": "Show email and calendar times in this time zone instead of the one reported by your browser",
"auto": "Automatic ({zone})",
"preview_now": "Now:"
}
},
"email_behavior": {
+6
View File
@@ -1179,6 +1179,12 @@
"description": "Comença la setmana en diumenge o dilluns",
"sunday": "Diumenge",
"monday": "Dilluns"
},
"time_zone": {
"label": "Time zone",
"description": "Show email and calendar times in this time zone instead of the one reported by your browser",
"auto": "Automatic ({zone})",
"preview_now": "Now:"
}
},
"email_behavior": {
+6
View File
@@ -1176,6 +1176,12 @@
"description": "Začátek týdne v neděli nebo v pondělí",
"sunday": "Neděle",
"monday": "Pondělí"
},
"time_zone": {
"label": "Time zone",
"description": "Show email and calendar times in this time zone instead of the one reported by your browser",
"auto": "Automatic ({zone})",
"preview_now": "Now:"
}
},
"email_behavior": {
+6
View File
@@ -1179,6 +1179,12 @@
"description": "Start ugen på søndag eller mandag",
"sunday": "Søndag",
"monday": "Mandag"
},
"time_zone": {
"label": "Time zone",
"description": "Show email and calendar times in this time zone instead of the one reported by your browser",
"auto": "Automatic ({zone})",
"preview_now": "Now:"
}
},
"email_behavior": {
+6
View File
@@ -1176,6 +1176,12 @@
"description": "Woche am Sonntag oder Montag beginnen",
"sunday": "Sonntag",
"monday": "Montag"
},
"time_zone": {
"label": "Time zone",
"description": "Show email and calendar times in this time zone instead of the one reported by your browser",
"auto": "Automatic ({zone})",
"preview_now": "Now:"
}
},
"email_behavior": {
+6
View File
@@ -1179,6 +1179,12 @@
"description": "Start week on Sunday or Monday",
"sunday": "Sunday",
"monday": "Monday"
},
"time_zone": {
"label": "Time zone",
"description": "Show email and calendar times in this time zone instead of the one reported by your browser",
"auto": "Automatic ({zone})",
"preview_now": "Now:"
}
},
"email_behavior": {
+6
View File
@@ -1176,6 +1176,12 @@
"description": "Iniciar la semana en domingo o lunes",
"sunday": "Domingo",
"monday": "Lunes"
},
"time_zone": {
"label": "Time zone",
"description": "Show email and calendar times in this time zone instead of the one reported by your browser",
"auto": "Automatic ({zone})",
"preview_now": "Now:"
}
},
"email_behavior": {
+6
View File
@@ -1179,6 +1179,12 @@
"description": "یکشنبه یا دوشنبه",
"sunday": "یکشنبه",
"monday": "دوشنبه"
},
"time_zone": {
"label": "Time zone",
"description": "Show email and calendar times in this time zone instead of the one reported by your browser",
"auto": "Automatic ({zone})",
"preview_now": "Now:"
}
},
"email_behavior": {
+6
View File
@@ -1176,6 +1176,12 @@
"description": "Commencer la semaine le dimanche ou le lundi",
"sunday": "Dimanche",
"monday": "Lundi"
},
"time_zone": {
"label": "Time zone",
"description": "Show email and calendar times in this time zone instead of the one reported by your browser",
"auto": "Automatic ({zone})",
"preview_now": "Now:"
}
},
"email_behavior": {
+6
View File
@@ -1141,6 +1141,12 @@
"description": "התחל את השבוע ביום ראשון או שני",
"sunday": "ראשון",
"monday": "שני"
},
"time_zone": {
"label": "Time zone",
"description": "Show email and calendar times in this time zone instead of the one reported by your browser",
"auto": "Automatic ({zone})",
"preview_now": "Now:"
}
},
"email_behavior": {
+6
View File
@@ -1179,6 +1179,12 @@
"description": "Hét kezdése vasárnappal vagy hétfővel",
"sunday": "Vasárnap",
"monday": "Hétfő"
},
"time_zone": {
"label": "Time zone",
"description": "Show email and calendar times in this time zone instead of the one reported by your browser",
"auto": "Automatic ({zone})",
"preview_now": "Now:"
}
},
"email_behavior": {
+6
View File
@@ -1176,6 +1176,12 @@
"description": "Inizia la settimana di domenica o lunedì",
"sunday": "Domenica",
"monday": "Lunedì"
},
"time_zone": {
"label": "Time zone",
"description": "Show email and calendar times in this time zone instead of the one reported by your browser",
"auto": "Automatic ({zone})",
"preview_now": "Now:"
}
},
"email_behavior": {
+6
View File
@@ -1176,6 +1176,12 @@
"description": "日曜日または月曜日から開始",
"sunday": "日曜日",
"monday": "月曜日"
},
"time_zone": {
"label": "Time zone",
"description": "Show email and calendar times in this time zone instead of the one reported by your browser",
"auto": "Automatic ({zone})",
"preview_now": "Now:"
}
},
"email_behavior": {
+6
View File
@@ -1176,6 +1176,12 @@
"description": "일주일이 일요일부터 시작할지 월요일부터 시작할지 설정해요",
"sunday": "일요일",
"monday": "월요일"
},
"time_zone": {
"label": "Time zone",
"description": "Show email and calendar times in this time zone instead of the one reported by your browser",
"auto": "Automatic ({zone})",
"preview_now": "Now:"
}
},
"email_behavior": {
+6
View File
@@ -1176,6 +1176,12 @@
"description": "Sākt nedēļu ar svētdienu vai pirmdienu",
"sunday": "Svētdiena",
"monday": "Pirmdiena"
},
"time_zone": {
"label": "Time zone",
"description": "Show email and calendar times in this time zone instead of the one reported by your browser",
"auto": "Automatic ({zone})",
"preview_now": "Now:"
}
},
"email_behavior": {
+6
View File
@@ -1179,6 +1179,12 @@
"description": "Долоо хоногийн Ням эсвэл Даваа гарагт эхэлнэ",
"sunday": "Ням гараг",
"monday": "Даваа гараг"
},
"time_zone": {
"label": "Time zone",
"description": "Show email and calendar times in this time zone instead of the one reported by your browser",
"auto": "Automatic ({zone})",
"preview_now": "Now:"
}
},
"email_behavior": {
+6
View File
@@ -1176,6 +1176,12 @@
"description": "Begin de week op zondag of maandag",
"sunday": "Zondag",
"monday": "Maandag"
},
"time_zone": {
"label": "Time zone",
"description": "Show email and calendar times in this time zone instead of the one reported by your browser",
"auto": "Automatic ({zone})",
"preview_now": "Now:"
}
},
"email_behavior": {
+6
View File
@@ -1176,6 +1176,12 @@
"description": "Rozpocznij tydzień w niedzielę lub poniedziałek",
"sunday": "Niedziela",
"monday": "Poniedziałek"
},
"time_zone": {
"label": "Time zone",
"description": "Show email and calendar times in this time zone instead of the one reported by your browser",
"auto": "Automatic ({zone})",
"preview_now": "Now:"
}
},
"email_behavior": {
+6
View File
@@ -1176,6 +1176,12 @@
"description": "Começar a semana no domingo ou segunda-feira",
"sunday": "Domingo",
"monday": "Segunda-feira"
},
"time_zone": {
"label": "Time zone",
"description": "Show email and calendar times in this time zone instead of the one reported by your browser",
"auto": "Automatic ({zone})",
"preview_now": "Now:"
}
},
"email_behavior": {
+6
View File
@@ -1179,6 +1179,12 @@
"description": "Începeți săptămâna duminică sau luni",
"sunday": "Duminică",
"monday": "Luni"
},
"time_zone": {
"label": "Time zone",
"description": "Show email and calendar times in this time zone instead of the one reported by your browser",
"auto": "Automatic ({zone})",
"preview_now": "Now:"
}
},
"email_behavior": {
+6
View File
@@ -1176,6 +1176,12 @@
"description": "Начинать неделю с воскресенья или понедельника",
"sunday": "Воскресенье",
"monday": "Понедельник"
},
"time_zone": {
"label": "Time zone",
"description": "Show email and calendar times in this time zone instead of the one reported by your browser",
"auto": "Automatic ({zone})",
"preview_now": "Now:"
}
},
"email_behavior": {
+6
View File
@@ -1179,6 +1179,12 @@
"description": "Začiatok týždňa v nedeľu alebo v pondelok",
"sunday": "Nedeľa",
"monday": "Pondelok"
},
"time_zone": {
"label": "Time zone",
"description": "Show email and calendar times in this time zone instead of the one reported by your browser",
"auto": "Automatic ({zone})",
"preview_now": "Now:"
}
},
"email_behavior": {
+6
View File
@@ -1176,6 +1176,12 @@
"description": "Haftayı Pazar veya Pazartesi başlat",
"sunday": "Pazar",
"monday": "Pazartesi"
},
"time_zone": {
"label": "Time zone",
"description": "Show email and calendar times in this time zone instead of the one reported by your browser",
"auto": "Automatic ({zone})",
"preview_now": "Now:"
}
},
"email_behavior": {
+6
View File
@@ -1176,6 +1176,12 @@
"description": "Почніть тиждень у неділю чи понеділок",
"sunday": "неділя",
"monday": "понеділок"
},
"time_zone": {
"label": "Time zone",
"description": "Show email and calendar times in this time zone instead of the one reported by your browser",
"auto": "Automatic ({zone})",
"preview_now": "Now:"
}
},
"email_behavior": {
+6
View File
@@ -1176,6 +1176,12 @@
"description": "从周日或周一开始一周",
"sunday": "周日",
"monday": "周一"
},
"time_zone": {
"label": "Time zone",
"description": "Show email and calendar times in this time zone instead of the one reported by your browser",
"auto": "Automatic ({zone})",
"preview_now": "Now:"
}
},
"email_behavior": {
+4 -3
View File
@@ -4,6 +4,7 @@ import type { IJMAPClient } from '@/lib/jmap/client-interface';
import type { Calendar, CalendarEvent, CalendarParticipant, CalendarRights, CreateCalendarOptions } from '@/lib/jmap/types';
import { debug } from '@/lib/debug';
import { normalizeAllDayDuration } from '@/lib/calendar-utils';
import { displayNow } from '@/lib/timezone';
import { parseDuration } from '@/components/calendar/event-card';
import { sanitizeOutgoingCalendarEventData } from '@/lib/calendar-event-normalization';
import { expandRecurringEvents } from '@/lib/recurrence-expansion';
@@ -301,7 +302,7 @@ interface CalendarStore {
const initialState = {
calendars: [],
events: [],
selectedDate: new Date(),
selectedDate: displayNow(),
selectedCalendarIds: [] as string[],
selectedEventId: null as string | null,
isLoading: false,
@@ -1310,7 +1311,7 @@ export const useCalendarStore = create<CalendarStore>()(
const preservedSubs = get().icalSubscriptions;
set({
...initialState,
selectedDate: new Date(),
selectedDate: displayNow(),
icalSubscriptions: preservedSubs,
});
import('./calendar-notification-store').then(({ useCalendarNotificationStore }) => {
@@ -1328,7 +1329,7 @@ export const useCalendarStore = create<CalendarStore>()(
return {
...mergedState,
selectedDate: new Date(),
selectedDate: displayNow(),
viewMode: getSafeCalendarViewMode(mergedState.viewMode),
};
},
+11
View File
@@ -114,6 +114,8 @@ export type DateFormat = 'smart' | 'relative' | 'full';
export type DateLocale = 'auto' | 'iso' | 'en-GB' | 'en-US';
export type TimeFormat = '12h' | '24h';
export type FirstDayOfWeek = 0 | 1 | 6; // 0 = Sunday, 1 = Monday, 6 = Saturday
/** IANA zone id that overrides the browser's, or 'auto' to follow the browser (#755). */
export type TimeZoneSetting = 'auto' | (string & {});
export type ExternalContentPolicy = 'ask' | 'block' | 'allow';
export type MailAttachmentAction = 'preview' | 'download';
export type AttachmentPosition = 'beside-sender' | 'below-header';
@@ -307,6 +309,7 @@ interface SettingsState {
dateLocale: DateLocale;
timeFormat: TimeFormat;
firstDayOfWeek: FirstDayOfWeek;
timeZone: TimeZoneSetting;
// Email Behavior
markAsReadDelay: number; // milliseconds (0 = instant, -1 = never)
@@ -540,6 +543,7 @@ const DEFAULT_SETTINGS = {
dateLocale: 'auto' as DateLocale,
timeFormat: '24h' as TimeFormat,
firstDayOfWeek: 1 as FirstDayOfWeek, // Monday
timeZone: 'auto' as TimeZoneSetting,
// Email Behavior
markAsReadDelay: 0, // Instant
@@ -765,6 +769,7 @@ export const useSettingsStore = create<SettingsState>()(
dateLocale: state.dateLocale,
timeFormat: state.timeFormat,
firstDayOfWeek: state.firstDayOfWeek,
timeZone: state.timeZone,
markAsReadDelay: state.markAsReadDelay,
deleteAction: state.deleteAction,
returnToListAfterAction: state.returnToListAfterAction,
@@ -888,6 +893,12 @@ export const useSettingsStore = create<SettingsState>()(
set({ sendDelaySeconds: 0 });
return;
}
// Validity of the zone id itself is checked at use time
// (lib/timezone resolveTimeZone falls back to the browser zone),
// so only reject non-strings here.
if (key === 'timeZone' && typeof settings[key] !== 'string') {
return;
}
// Ignore a legacy global allMailFolderIds (string[] | null) or any
// non-record value - this build keys it per account.
if (key === 'allMailFolderIds' && !isPlainRecord(settings[key])) {