mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-30 16:16:19 +00:00
feat: add custom time zone setting overriding browser detection #755
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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? */
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,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),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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])) {
|
||||
|
||||
Reference in New Issue
Block a user