From a8e6f6e8c4b4ff9794d506f59e5178724e73f770 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:30:49 +0200 Subject: [PATCH] fix: keep event and task times when the calendar time zone is not the device's With the calendar time-zone setting away from the device zone, the event and task editors showed device-local times but saved them labelled with the calendar zone, so opening an event and saving it unchanged moved it by the difference between the two zones. The calendar now works in display dates like the webmail (`toDisplayDate` / `fromDisplayDate`): Dates whose local fields read as the wall clock in the calendar zone. They are the identity while the calendar follows the device. - `getEventStartDate` / `getEventEndDate` return display dates, so the views, the detail sheet and the editor show calendar-zone times - the editors convert back through `eventTimeFieldsToSave` and `buildTaskEditorChanges`: a start, end or due the user left alone is saved exactly as stored, a changed one is converted into the zone the event or task lives in (new, floating and formerly all-day ones take the calendar zone), and durations count whole days as calendar days in that zone - `getTaskDueDate` stays the real instant for reminders; the tasks sheet uses `getTaskDueDisplayDate`, `displayNow` and `isDisplayToday` - wall clocks are parsed from their digits, so one inside the device's own DST gap still converts right - duplicating an event keeps its wall clock in its own zone instead of reading the device time --- src/components/calendar/EventModal.tsx | 51 +--- src/components/calendar/TasksSheet.tsx | 19 +- src/lib/__tests__/event-editor-times.test.ts | 250 +++++++++++++++++++ src/lib/calendar-timezone.ts | 155 +++++++++++- src/lib/calendar-utils.ts | 58 ++++- src/lib/event-editor-times.ts | 164 ++++++++++++ src/lib/task-editor.ts | 55 +++- src/screens/CalendarScreen.tsx | 7 +- 8 files changed, 688 insertions(+), 71 deletions(-) create mode 100644 src/lib/__tests__/event-editor-times.test.ts create mode 100644 src/lib/event-editor-times.ts diff --git a/src/components/calendar/EventModal.tsx b/src/components/calendar/EventModal.tsx index 35d2b7bd..435c163f 100644 --- a/src/components/calendar/EventModal.tsx +++ b/src/components/calendar/EventModal.tsx @@ -37,15 +37,12 @@ import type { import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; import { - buildAllDayDuration, getCalendarColor, - getEventDisplayEndDate, - getEventEndDate, - getEventStartDate, getPrimaryCalendarId, timePattern, } from '../../lib/calendar-utils'; -import { getEffectiveTimeZone } from '../../lib/calendar-timezone'; +import { displayNow } from '../../lib/calendar-timezone'; +import { editorTimesFromEvent, eventTimeFieldsToSave } from '../../lib/event-editor-times'; import { useSettingsStore } from '../../stores/settings-store'; import { canCreateEventsIn } from '../../lib/calendar-editability'; import { useCalendarLocale } from '../../lib/calendar-locale'; @@ -110,21 +107,6 @@ function nextHalfHour(d: Date): Date { return date; } -function buildDuration(start: Date, end: Date): string { - const diffMin = Math.max(0, Math.floor((end.getTime() - start.getTime()) / 60000)); - const days = Math.floor(diffMin / (24 * 60)); - const hours = Math.floor((diffMin % (24 * 60)) / 60); - const mins = diffMin % 60; - let dur = 'P'; - if (days > 0) dur += `${days}D`; - if (hours > 0 || mins > 0) { - dur += 'T'; - if (hours > 0) dur += `${hours}H`; - if (mins > 0) dur += `${mins}M`; - } - return dur === 'P' ? 'PT0M' : dur; -} - function detectRecurrence(event: CalendarEvent | null | undefined): RecurrenceOption { const rule = event?.recurrenceRules?.[0]; if (!rule) return 'none'; @@ -193,8 +175,8 @@ export function EventModal({ const [title, setTitle] = React.useState(''); const [description, setDescription] = React.useState(''); const [allDay, setAllDay] = React.useState(false); - const [start, setStart] = React.useState(() => nextHalfHour(defaultDate ?? new Date())); - const [end, setEnd] = React.useState(() => addHours(nextHalfHour(defaultDate ?? new Date()), 1)); + const [start, setStart] = React.useState(() => nextHalfHour(defaultDate ?? displayNow())); + const [end, setEnd] = React.useState(() => addHours(nextHalfHour(defaultDate ?? displayNow()), 1)); // New events land in the explicitly requested calendar, else the account's // default calendar, else the first one. // Never default new events into a subscription / read-only calendar (#762). @@ -239,12 +221,11 @@ export function EventModal({ if (event) { setTitle(event.title || ''); setDescription(event.description || ''); - setAllDay(!!event.showWithoutTime); - setStart(getEventStartDate(event)); - // All-day events store an exclusive end (start + P1D = next day 00:00); - // the editor works with the inclusive last day, otherwise every re-save - // grows the event by one day. - setEnd(event.showWithoutTime ? getEventDisplayEndDate(event) : getEventEndDate(event)); + // In the calendar's time zone, like the grid and the detail sheet. + const times = editorTimesFromEvent(event); + setAllDay(times.allDay); + setStart(times.start); + setEnd(times.end); setCalendarId(getPrimaryCalendarId(event) || calendars[0]?.id || ''); setAttendees(seedAttendees(event, currentUserEmails)); const detected = detectRecurrence(event); @@ -258,7 +239,7 @@ export function EventModal({ setLocation(detectLocation(event)); setVideoUrl(detectVideoUrl(event)); } else { - const d = defaultDate ? nextHalfHour(defaultDate) : nextHalfHour(new Date()); + const d = nextHalfHour(defaultDate ?? displayNow()); setTitle(''); setDescription(''); setAllDay(false); @@ -337,15 +318,9 @@ export function EventModal({ const data: Partial = { title: title.trim(), description: description.trim() || (isEdit && event?.description ? '' : undefined), - showWithoutTime: allDay || (isEdit ? false : undefined), - start: allDay - ? format(start, "yyyy-MM-dd'T'00:00:00") - : format(start, "yyyy-MM-dd'T'HH:mm:ss"), - duration: allDay ? buildAllDayDuration(start, end) : buildDuration(start, end), - // A floating (zone-less) start renders at a different instant for - // every viewer; label the wall-clock with the zone it was entered in. - // All-day events are date-only and carry no zone. - timeZone: allDay ? null : getEffectiveTimeZone(), + // The times shown are in the calendar's zone; this converts them back + // into the event's own zone and keeps untouched ones as stored. + ...eventTimeFieldsToSave({ allDay, start, end }, event), recurrenceRules: clearedOr(recurrenceRules, 'recurrenceRules'), alerts: clearedOr(remindersToAlerts(reminders, preserved), 'alerts'), useDefaultAlerts: reminders.length > 0 ? false : undefined, diff --git a/src/components/calendar/TasksSheet.tsx b/src/components/calendar/TasksSheet.tsx index 41ac9e07..317ad61e 100644 --- a/src/components/calendar/TasksSheet.tsx +++ b/src/components/calendar/TasksSheet.tsx @@ -25,11 +25,12 @@ import { Trash2, X, } from 'lucide-react-native'; -import { format, isBefore, isToday, startOfDay } from 'date-fns'; +import { format, isBefore, startOfDay } from 'date-fns'; import type { Calendar, CalendarEvent } from '../../api/types'; import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; -import { getCalendarColor, getTaskDueDate, timePattern, type TimeFormat } from '../../lib/calendar-utils'; +import { getCalendarColor, getTaskDueDisplayDate, timePattern, type TimeFormat } from '../../lib/calendar-utils'; +import { displayNow, isDisplayToday } from '../../lib/calendar-timezone'; import { emptyTaskEditor, priorityToLevel, @@ -64,9 +65,9 @@ function isCompleted(task: CalendarEvent): boolean { function isOverdue(task: CalendarEvent): boolean { if (isCompleted(task)) return false; - const d = getTaskDueDate(task); + const d = getTaskDueDisplayDate(task); if (!d) return false; - return isBefore(d, startOfDay(new Date())) && !isToday(d); + return isBefore(d, startOfDay(displayNow())) && !isDisplayToday(d); } // Open tasks first, overdue first among them, then by due date (soonest @@ -78,8 +79,8 @@ function compareTasks(a: CalendarEvent, b: CalendarEvent): number { const ao = isOverdue(a); const bo = isOverdue(b); if (ao !== bo) return ao ? -1 : 1; - const ad = getTaskDueDate(a)?.getTime() ?? Infinity; - const bd = getTaskDueDate(b)?.getTime() ?? Infinity; + const ad = getTaskDueDisplayDate(a)?.getTime() ?? Infinity; + const bd = getTaskDueDisplayDate(b)?.getTime() ?? Infinity; if (ad !== bd) return ad - bd; const ap = a.priority || 10; const bp = b.priority || 10; @@ -201,7 +202,7 @@ export function TasksSheet({ }; const dueLabel = (task: CalendarEvent): string | null => { - const d = getTaskDueDate(task); + const d = getTaskDueDisplayDate(task); if (!d || !task.due) return null; const hasTime = !task.showWithoutTime && !/^\d{4}-\d{2}-\d{2}$/.test(task.due); return hasTime @@ -419,7 +420,7 @@ export function TasksSheet({ {showDatePicker && ( { @@ -437,7 +438,7 @@ export function TasksSheet({ )} {showTimePicker && ( { diff --git a/src/lib/__tests__/event-editor-times.test.ts b/src/lib/__tests__/event-editor-times.test.ts new file mode 100644 index 00000000..c519485e --- /dev/null +++ b/src/lib/__tests__/event-editor-times.test.ts @@ -0,0 +1,250 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// The device is in New York, the calendar is set to Berlin: every time the +// editors show is a Berlin wall clock held in a New York Date. +vi.hoisted(() => { + process.env.TZ = 'America/New_York'; +}); + +import { useSettingsStore } from '../../stores/settings-store'; +import { + displayNow, + fromDisplayDate, + getDeviceTimeZone, + localDateTimeToInstant, + toDisplayDate, +} from '../calendar-timezone'; +import { getEventStartDate, getTaskDueDate, getTaskDueDisplayDate } from '../calendar-utils'; +import { + durationBetween, + editorTimesFromEvent, + eventTimeFieldsToSave, + type EditorTimes, +} from '../event-editor-times'; +import { buildTaskEditorChanges, taskEditorFromTask } from '../task-editor'; +import type { CalendarEvent } from '../../api/types'; + +function ev(partial: Partial): CalendarEvent { + return { id: 'e1', uid: 'u', title: 'Standup', calendarIds: { c: true }, ...partial } as CalendarEvent; +} + +function shift(times: EditorTimes, part: 'start' | 'end' | 'both', minutes: number): EditorTimes { + const move = (d: Date) => new Date(d.getTime() + minutes * 60_000); + return { + ...times, + start: part === 'end' ? times.start : move(times.start), + end: part === 'start' ? times.end : move(times.end), + }; +} + +beforeEach(() => { + useSettingsStore.setState({ calendarTimeZone: 'Europe/Berlin' }); +}); + +afterEach(() => { + useSettingsStore.setState({ calendarTimeZone: 'auto' }); +}); + +describe('display dates', () => { + it('runs on a device zone other than the calendar zone', () => { + expect(getDeviceTimeZone()).toBe('America/New_York'); + }); + + it('shows an instant as the wall clock in the calendar zone and converts it back', () => { + const instant = new Date('2026-01-15T08:00:00Z'); // 09:00 Berlin, 03:00 New York + const shown = toDisplayDate(instant); + expect([shown.getDate(), shown.getHours(), shown.getMinutes()]).toEqual([15, 9, 0]); + expect(fromDisplayDate(shown).toISOString()).toBe('2026-01-15T08:00:00.000Z'); + expect(displayNow().getTime()).not.toBe(Number.NaN); + }); + + it('is the identity when the calendar follows the device', () => { + useSettingsStore.setState({ calendarTimeZone: 'auto' }); + const instant = new Date('2026-01-15T08:00:00Z'); + expect(toDisplayDate(instant)).toBe(instant); + }); + + it('reads a wall clock the device skips (its own DST gap) from the digits', () => { + // 02:30 on 8 March 2026 does not exist in New York; in Berlin it does. + expect(localDateTimeToInstant('2026-03-08T02:30:00', 'Europe/Berlin')?.toISOString()) + .toBe('2026-03-08T01:30:00.000Z'); + }); + + it('places events and task dues in the calendar zone, reminders at the instant', () => { + const start = getEventStartDate(ev({ start: '2026-01-15T09:00:00', timeZone: 'America/Los_Angeles', utcStart: '2026-01-15T17:00:00Z' })); + expect([start.getDate(), start.getHours()]).toEqual([15, 18]); + // Without utcStart the start is converted from its own zone. + const noUtc = getEventStartDate(ev({ start: '2026-01-15T09:00:00', timeZone: 'America/Los_Angeles' })); + expect(noUtc.getHours()).toBe(18); + + const task = { due: '2026-07-01T17:00:00', timeZone: 'America/Los_Angeles' }; + expect(getTaskDueDate(task)?.toISOString()).toBe('2026-07-02T00:00:00.000Z'); + const shown = getTaskDueDisplayDate(task)!; + expect([shown.getDate(), shown.getHours()]).toEqual([2, 2]); + }); +}); + +describe('event editor round trip', () => { + it('saves an untouched event exactly as stored', () => { + const events = [ + ev({ start: '2026-01-15T09:00:00', timeZone: 'Europe/Berlin', duration: 'PT1H', utcStart: '2026-01-15T08:00:00Z', utcEnd: '2026-01-15T09:00:00Z' }), + ev({ start: '2026-01-15T09:00:00', timeZone: 'America/Los_Angeles', duration: 'PT45M', utcStart: '2026-01-15T17:00:00Z', utcEnd: '2026-01-15T17:45:00Z' }), + ev({ start: '2026-01-15T09:00:00', timeZone: null, duration: 'PT1H', utcStart: '2026-01-15T08:00:00Z', utcEnd: '2026-01-15T09:00:00Z' }), + ev({ start: '2026-03-29T00:00:00', showWithoutTime: true, timeZone: null, duration: 'P1D' }), + ev({ start: '2026-03-27T00:00:00', showWithoutTime: true, timeZone: null, duration: 'P3D' }), + ]; + for (const event of events) { + expect(eventTimeFieldsToSave(editorTimesFromEvent(event), event)).toEqual({ + start: event.start, + duration: event.duration, + timeZone: event.showWithoutTime ? null : event.timeZone, + showWithoutTime: !!event.showWithoutTime, + }); + } + }); + + it('shows an event from another zone in the calendar zone and keeps it in its own zone', () => { + const event = ev({ + start: '2026-01-15T09:00:00', timeZone: 'America/Los_Angeles', duration: 'PT1H', + utcStart: '2026-01-15T17:00:00Z', utcEnd: '2026-01-15T18:00:00Z', + }); + const times = editorTimesFromEvent(event); + expect(times.start.getHours()).toBe(18); + + expect(eventTimeFieldsToSave(shift(times, 'both', 60), event)).toEqual({ + start: '2026-01-15T10:00:00', + duration: 'PT1H', + timeZone: 'America/Los_Angeles', + showWithoutTime: false, + }); + }); + + it('keeps a floating start floating unless the start itself changes', () => { + const event = ev({ + start: '2026-01-15T09:00:00', timeZone: null, duration: 'PT1H', + utcStart: '2026-01-15T08:00:00Z', utcEnd: '2026-01-15T09:00:00Z', + }); + const times = editorTimesFromEvent(event); + expect(times.start.getHours()).toBe(9); + + expect(eventTimeFieldsToSave(shift(times, 'end', 30), event)).toEqual({ + start: '2026-01-15T09:00:00', duration: 'PT1H30M', timeZone: null, showWithoutTime: false, + }); + // A moved start is labelled with the zone it was entered in. + expect(eventTimeFieldsToSave(shift(times, 'both', 60), event)).toEqual({ + start: '2026-01-15T10:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin', showWithoutTime: false, + }); + }); + + it('keeps all-day events on their dates', () => { + const event = ev({ start: '2026-03-29T00:00:00', showWithoutTime: true, timeZone: null, duration: 'P1D' }); + const times = editorTimesFromEvent(event); + expect([times.start.getDate(), times.end.getDate()]).toEqual([29, 29]); + + const longer = { ...times, end: new Date(2026, 2, 30) }; + expect(eventTimeFieldsToSave(longer, event)).toEqual({ + start: '2026-03-29T00:00:00', duration: 'P2D', timeZone: null, showWithoutTime: true, + }); + }); + + it('keeps the real length across a DST change in the calendar zone', () => { + // 01:30 CET -> 03:30 CEST on 29 March 2026 in Berlin: one hour, though + // the wall clocks (and the display dates) are two hours apart. + const event = ev({ + start: '2026-03-29T01:30:00', timeZone: 'Europe/Berlin', duration: 'PT1H', + utcStart: '2026-03-29T00:30:00Z', utcEnd: '2026-03-29T01:30:00Z', + }); + const times = editorTimesFromEvent(event); + expect([times.start.getHours(), times.end.getHours()]).toEqual([1, 3]); + + expect(eventTimeFieldsToSave(times, event).duration).toBe('PT1H'); + // Starting half an hour earlier keeps the end where it was. + expect(eventTimeFieldsToSave(shift(times, 'start', -30), event)).toEqual({ + start: '2026-03-29T01:00:00', duration: 'PT1H30M', timeZone: 'Europe/Berlin', showWithoutTime: false, + }); + }); + + it('never moves an event whose calendar-zone time does not exist on the device', () => { + // 02:30 Berlin on 8 March 2026 falls into New York's spring-forward gap, + // so its display date reads 03:30. + const event = ev({ + start: '2026-03-08T02:30:00', timeZone: 'Europe/Berlin', duration: 'PT1H', + utcStart: '2026-03-08T01:30:00Z', utcEnd: '2026-03-08T02:30:00Z', + }); + const times = editorTimesFromEvent(event); + expect(times.start.getHours()).toBe(3); + + expect(eventTimeFieldsToSave(times, event)).toMatchObject({ start: '2026-03-08T02:30:00', duration: 'PT1H' }); + // Only the end moved: the start stays as stored, the length is exact. + expect(eventTimeFieldsToSave(shift(times, 'end', 60), event)).toEqual({ + start: '2026-03-08T02:30:00', duration: 'PT2H', timeZone: 'Europe/Berlin', showWithoutTime: false, + }); + }); + + it('saves a new event in the calendar zone', () => { + const times = { allDay: false, start: new Date(2026, 0, 15, 9, 0), end: new Date(2026, 0, 15, 10, 30) }; + expect(eventTimeFieldsToSave(times)).toEqual({ + start: '2026-01-15T09:00:00', duration: 'PT1H30M', timeZone: 'Europe/Berlin', showWithoutTime: undefined, + }); + }); + + it('counts whole days as calendar days in the event zone', () => { + const at = (value: string) => localDateTimeToInstant(value, 'Europe/Berlin')!; + // 47 hours across the change, but two calendar days. + expect(durationBetween(at('2026-03-28T10:00:00'), at('2026-03-30T10:00:00'), 'Europe/Berlin')).toBe('P2D'); + expect(durationBetween(at('2026-03-28T10:00:00'), at('2026-03-29T12:00:00'), 'Europe/Berlin')).toBe('P1DT2H'); + expect(durationBetween(at('2026-01-15T09:00:00'), at('2026-01-15T09:45:00'), 'Europe/Berlin')).toBe('PT45M'); + }); +}); + +describe('task editor round trip', () => { + const task = (partial: Partial) => + ({ id: 't1', title: 'Pay rent', calendarIds: { c: true }, ...partial }) as CalendarEvent; + + it('sends no due for an untouched task', () => { + for (const t of [ + task({ due: '2026-07-01T17:00:00', timeZone: 'America/Los_Angeles' }), + task({ due: '2026-07-01T17:00:00' }), + task({ due: '2026-07-01' }), + task({ due: '2026-07-01T00:00:00', showWithoutTime: true }), + task({ due: '2026-03-08T02:30:00', timeZone: 'Europe/Berlin' }), + ]) { + const changes = buildTaskEditorChanges(taskEditorFromTask(t, 'c'), t); + expect(changes).not.toHaveProperty('due'); + expect(changes).not.toHaveProperty('timeZone'); + expect(changes).not.toHaveProperty('showWithoutTime'); + } + }); + + it('shows a due in the calendar zone and saves a change in the task\'s own zone', () => { + const t = task({ due: '2026-07-01T17:00:00', timeZone: 'America/Los_Angeles' }); + const editor = taskEditorFromTask(t, 'c'); + expect([editor.due!.getDate(), editor.due!.getHours()]).toEqual([2, 2]); + + const later = { ...editor, due: new Date(editor.due!.getTime() + 60 * 60_000) }; + expect(buildTaskEditorChanges(later, t)).toMatchObject({ + due: '2026-07-01T18:00:00', timeZone: 'America/Los_Angeles', showWithoutTime: false, + }); + }); + + it('labels a changed floating due with the calendar zone', () => { + const t = task({ due: '2026-07-01T17:00:00' }); + const editor = taskEditorFromTask(t, 'c'); + expect(editor.due!.getHours()).toBe(17); + + const later = { ...editor, due: new Date(editor.due!.getTime() + 60 * 60_000) }; + expect(buildTaskEditorChanges(later, t)).toMatchObject({ + due: '2026-07-01T18:00:00', timeZone: 'Europe/Berlin', showWithoutTime: false, + }); + }); + + it('keeps a date-only due a date', () => { + const t = task({ due: '2026-07-01' }); + const editor = taskEditorFromTask(t, 'c'); + expect(editor.withTime).toBe(false); + + expect(buildTaskEditorChanges({ ...editor, due: new Date(2026, 6, 2) }, t)).toMatchObject({ + due: '2026-07-02T00:00:00', showWithoutTime: true, timeZone: null, + }); + }); +}); diff --git a/src/lib/calendar-timezone.ts b/src/lib/calendar-timezone.ts index d51d6aaa..7bdef412 100644 --- a/src/lib/calendar-timezone.ts +++ b/src/lib/calendar-timezone.ts @@ -66,7 +66,10 @@ export function resolveTimeZone( return isValidTimeZone(setting) ? setting : deviceTimeZone; } -/** The zone new timed events are saved in and JMAP queries are interpreted in. */ +/** + * The zone the calendar shows and edits times in, new timed events are + * saved in and JMAP queries are interpreted in. + */ export function getEffectiveTimeZone(): string { let setting: string | null | undefined; try { @@ -76,3 +79,153 @@ export function getEffectiveTimeZone(): string { } return resolveTimeZone(setting); } + +// ─── Display dates ─────────────────────────────────────── +// The calendar's grid math, the pickers and date-fns `format` all read the +// *local* getters of a Date. 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 calendar's zone, and shifted back +// when a time the user picked has to become a real instant. Both are the +// identity while the calendar zone is the device zone. Port of the webmail's +// lib/timezone.ts (toDisplayDate / fromDisplayDate). + +export interface WallClock { + year: number; + /** 1-12 */ + month: number; + day: number; + hour: number; + minute: number; + second: number; +} + +// Building an Intl.DateTimeFormat is expensive; the calendar converts every +// event it shows, so keep one per zone. +const wallClockFormatters = new Map(); + +function wallClockFormatter(timeZone: string): Intl.DateTimeFormat { + let formatter = wallClockFormatters.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', + }); + wallClockFormatters.set(timeZone, formatter); + } + return formatter; +} + +/** Wall-clock fields of the instant `date` as seen in `timeZone`. */ +export function getWallClock(date: Date, timeZone: string): WallClock { + const map: Record = {}; + for (const part of wallClockFormatter(timeZone).formatToParts(date)) { + 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, + }; +} + +/** The instant `date` as a JSCalendar LocalDateTime ("yyyy-MM-ddTHH:mm:ss") in `timeZone`. */ +export function formatWallClock(date: Date, timeZone: string): string { + const w = getWallClock(date, timeZone); + const pad = (n: number, len = 2) => String(n).padStart(len, '0'); + return `${pad(w.year, 4)}-${pad(w.month)}-${pad(w.day)}T${pad(w.hour)}:${pad(w.minute)}:${pad(w.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; +} + +/** 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`. Not the same instant unless the zones agree: only 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()); +} + +/** + * Read the local fields of `wall` as a wall clock in `timeZone` and return + * the real instant. 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)); +} + +/** + * The instant of a JSCalendar LocalDateTime ("yyyy-MM-ddTHH:mm:ss") read as + * a wall clock in `timeZone`. Parsed from the digits, so a wall clock that + * does not exist on the device (its own DST gap) still converts right. + * `null` for an unparsable value or zone. + */ +export function localDateTimeToInstant(value: string, timeZone: string): Date | null { + const m = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2}))?)?/.exec(value); + if (!m || !isValidTimeZone(timeZone)) return null; + const asUtc = Date.UTC( + Number(m[1]), Number(m[2]) - 1, Number(m[3]), + Number(m[4] ?? 0), Number(m[5] ?? 0), Number(m[6] ?? 0), + ); + const guess = asUtc - getTimeZoneOffsetMs(new Date(asUtc), timeZone); + return new Date(asUtc - getTimeZoneOffsetMs(new Date(guess), timeZone)); +} + +/** Instant -> display date. The identity unless the calendar zone differs from the device's. */ +export function toDisplayDate(date: Date): Date { + const timeZone = getEffectiveTimeZone(); + if (timeZone === getDeviceTimeZone()) return date; + return toZonedDisplayDate(date, timeZone); +} + +/** Display date (picked in the calendar or an editor) -> real instant. Inverse of `toDisplayDate`. */ +export function fromDisplayDate(date: Date): Date { + const timeZone = getEffectiveTimeZone(); + if (timeZone === getDeviceTimeZone()) return date; + return fromZonedDisplayDate(date, timeZone); +} + +/** "Now" as a display date: what a clock in the calendar's zone reads. */ +export function displayNow(): Date { + return toDisplayDate(new Date()); +} + +/** Is the display date `date` on today's calendar day in the calendar's zone? */ +export function isDisplayToday(date: Date): boolean { + const now = displayNow(); + return date.getFullYear() === now.getFullYear() + && date.getMonth() === now.getMonth() + && date.getDate() === now.getDate(); +} diff --git a/src/lib/calendar-utils.ts b/src/lib/calendar-utils.ts index 80e2876b..166f5dd0 100644 --- a/src/lib/calendar-utils.ts +++ b/src/lib/calendar-utils.ts @@ -8,7 +8,7 @@ import { import type { Calendar, CalendarEvent } from '../api/types'; import { colors } from '../theme/tokens'; import { zonedWallTimeToUtc } from './recurrence-expansion'; -import { getEffectiveTimeZone } from './calendar-timezone'; +import { getEffectiveTimeZone, localDateTimeToInstant, toDisplayDate } from './calendar-timezone'; // ─── Duration ──────────────────────────────────────────── // Parse an ISO 8601 duration ("PT1H30M", "P2D", "PT45M") to milliseconds. @@ -34,15 +34,29 @@ export function parseLocalDateTime(iso: string): Date { } // ─── Event time-range ──────────────────────────────────── +/** + * Start of an event as a *display date* (calendar-timezone): a Date whose + * local getters read as the wall clock in the calendar's time zone, which is + * what the grid math, the detail sheet and the editor work with. The real + * instant while the calendar zone is the device zone; convert a picked time + * back with `fromDisplayDate`. All-day events are calendar dates and stay as + * they are. Mirrors the webmail's getEventStartDate. + */ export function getEventStartDate( - event: Pick, + event: Pick, ): Date { // Prefer utcStart for timed events but fall back to start if utcStart is // missing or unparseable — a malformed utcStart used to surface as an // Invalid Date that silently dropped the event from every view (#316). if (!event.showWithoutTime && event.utcStart) { const utc = parseISO(event.utcStart); - if (!isNaN(utc.getTime())) return utc; + if (!isNaN(utc.getTime())) return toDisplayDate(utc); + } + // Without utcStart, a start in a zone of its own is converted here; a + // floating one is a wall clock in the calendar's zone already. + if (!event.showWithoutTime && event.timeZone) { + const instant = localDateTimeToInstant(event.start, event.timeZone); + if (instant) return toDisplayDate(instant); } return parseISO(event.start); } @@ -55,20 +69,52 @@ export function getEventStartDate( // computes utcStart for floating events (every query carries that zone), so // a floating task and a floating event at 17:00 land at the same instant. // Date-only and all-day dues are calendar dates and stay as they are. +// This is the real instant (reminders fire at it); what the calendar shows +// is getTaskDueDisplayDate. export function getTaskDueDate( task: Pick, ): Date | null { if (!task.due) return null; const wall = parseISO(task.due); if (isNaN(wall.getTime())) return null; - if (task.showWithoutTime || /^\d{4}-\d{2}-\d{2}$/.test(task.due)) return wall; - return zonedWallTimeToUtc(wall, task.timeZone || getEffectiveTimeZone()) ?? wall; + if (isDateOnlyDue(task)) return wall; + return localDateTimeToInstant(task.due, task.timeZone || getEffectiveTimeZone()) + ?? zonedWallTimeToUtc(wall, task.timeZone || getEffectiveTimeZone()) + ?? wall; } +/** A due without a time of day: a date-only value or an all-day task. */ +export function isDateOnlyDue(task: Pick): boolean { + return !!task.showWithoutTime || /^\d{4}-\d{2}-\d{2}$/.test(task.due ?? ''); +} + +/** A task's due as a display date (see getEventStartDate), for the tasks list and editor. */ +export function getTaskDueDisplayDate( + task: Pick, +): Date | null { + const due = getTaskDueDate(task); + if (!due || isDateOnlyDue(task)) return due; + return toDisplayDate(due); +} + +/** + * A JSCalendar LocalDateTime moved by whole calendar days, wall clock kept: + * the same time of day in the same zone (duplicating an event one day + * later). Returned unchanged when it doesn't parse. + */ +export function addDaysToLocalDateTime(value: string, days: number): string { + const m = /^(\d{4})-(\d{2})-(\d{2})(.*)$/.exec(value); + if (!m) return value; + const d = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]) + days)); + const pad = (n: number, len = 2) => String(n).padStart(len, '0'); + return `${pad(d.getUTCFullYear(), 4)}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}${m[4]}`; +} + +/** 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); if (!event.duration) return start; diff --git a/src/lib/event-editor-times.ts b/src/lib/event-editor-times.ts new file mode 100644 index 00000000..d8807b83 --- /dev/null +++ b/src/lib/event-editor-times.ts @@ -0,0 +1,164 @@ +import { format, parseISO } from 'date-fns'; +import type { CalendarEvent } from '../api/types'; +import { + buildAllDayDuration, + getEventDisplayEndDate, + getEventEndDate, + getEventStartDate, + parseDuration, +} from './calendar-utils'; +import { + formatWallClock, + fromDisplayDate, + getEffectiveTimeZone, + getWallClock, + isValidTimeZone, + localDateTimeToInstant, +} from './calendar-timezone'; + +// The event editor's times and what they save as. The editor shows and +// edits display dates (calendar-timezone): wall clocks in the calendar's time +// zone, like the grid and the detail sheet. Saving turns them back into the +// event's own start / duration / zone. A time the user did not touch is +// written back exactly as it came, so opening and saving an event never +// moves it, whatever the device zone, the calendar zone or a DST change in +// between. + +/** What the editor shows. `end` is the inclusive last day of an all-day event. */ +export interface EditorTimes { + allDay: boolean; + start: Date; + end: Date; +} + +export function editorTimesFromEvent(event: CalendarEvent): EditorTimes { + return { + allDay: !!event.showWithoutTime, + start: getEventStartDate(event), + // All-day events store an exclusive end (start + P1D = next day 00:00); + // the editor works with the inclusive last day, otherwise every re-save + // grows the event by one day. + end: event.showWithoutTime ? getEventDisplayEndDate(event) : getEventEndDate(event), + }; +} + +export type EventTimeFields = Pick; + +function pad(n: number, len = 2): string { + return String(n).padStart(len, '0'); +} + +function formatDuration(days: number, minutes: number): string { + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + let dur = 'P'; + if (days > 0) dur += `${days}D`; + if (hours > 0 || mins > 0) { + dur += 'T'; + if (hours > 0) dur += `${hours}H`; + if (mins > 0) dur += `${mins}M`; + } + return dur === 'P' ? 'PT0M' : dur; +} + +/** + * The JSCalendar duration from `start` to `end` for an event in `timeZone`. + * Whole days are calendar days in that zone (RFC 8984 adds them nominally, + * so a day across a DST change is 23 or 25 hours), the rest is exact time. + */ +export function durationBetween(start: Date, end: Date, timeZone: string): string { + const totalMinutes = Math.max(0, Math.floor((end.getTime() - start.getTime()) / 60000)); + if (totalMinutes < 24 * 60) return formatDuration(0, totalMinutes); + const s = getWallClock(start, timeZone); + const e = getWallClock(end, timeZone); + let days = Math.round( + (Date.UTC(e.year, e.month - 1, e.day) - Date.UTC(s.year, s.month - 1, s.day)) / 86_400_000, + ); + const startSecond = s.hour * 3600 + s.minute * 60 + s.second; + const endSecond = e.hour * 3600 + e.minute * 60 + e.second; + if (endSecond < startSecond) days -= 1; + const anchorDay = new Date(Date.UTC(s.year, s.month - 1, s.day + days)); + const anchor = localDateTimeToInstant( + `${pad(anchorDay.getUTCFullYear(), 4)}-${pad(anchorDay.getUTCMonth() + 1)}-${pad(anchorDay.getUTCDate())}` + + `T${pad(s.hour)}:${pad(s.minute)}:${pad(s.second)}`, + timeZone, + ); + if (!anchor) return formatDuration(0, totalMinutes); + const rest = Math.max(0, Math.floor((end.getTime() - anchor.getTime()) / 60000)); + return formatDuration(Math.max(0, days), rest); +} + +/** The real instant an event starts at. */ +function startInstantOf(event: CalendarEvent, zone: string): Date { + if (event.utcStart) { + const utc = parseISO(event.utcStart); + if (!isNaN(utc.getTime())) return utc; + } + return localDateTimeToInstant(event.start, event.timeZone || zone) + ?? fromDisplayDate(parseISO(event.start)); +} + +/** The real instant an event ends at. */ +function endInstantOf(event: CalendarEvent, zone: string): Date { + if (event.utcEnd) { + const utc = parseISO(event.utcEnd); + if (!isNaN(utc.getTime())) return utc; + } + return new Date(startInstantOf(event, zone).getTime() + parseDuration(event.duration)); +} + +/** + * The start / duration / time zone to save for the editor's `times`. + * `event` is the event being edited (absent for a new one). + * + * - A start or end the user left alone is kept as stored: an unchanged + * event saves its own start, duration and zone. + * - A changed time is converted from the calendar's zone into the zone the + * event lives in, so a New York meeting edited from a Berlin calendar + * stays a New York meeting. New, floating and formerly all-day events + * take the calendar's zone. + */ +export function eventTimeFieldsToSave(times: EditorTimes, event?: CalendarEvent | null): EventTimeFields { + const seed = event ? editorTimesFromEvent(event) : null; + const sameKind = !!seed && seed.allDay === times.allDay; + const startSame = sameKind && seed!.start.getTime() === times.start.getTime(); + const endSame = sameKind && seed!.end.getTime() === times.end.getTime(); + + if (times.allDay) { + if (event && startSame && endSame) { + return { start: event.start, duration: event.duration, timeZone: null, showWithoutTime: true }; + } + return { + start: format(times.start, "yyyy-MM-dd'T'00:00:00"), + duration: buildAllDayDuration(times.start, times.end), + timeZone: null, + showWithoutTime: true, + }; + } + + const showWithoutTime = event ? false : undefined; + if (event && startSame && endSame) { + return { start: event.start, duration: event.duration, timeZone: event.timeZone, showWithoutTime }; + } + const calendarZone = getEffectiveTimeZone(); + const ownZone = event && !event.showWithoutTime && isValidTimeZone(event.timeZone) ? event.timeZone : null; + const zone = ownZone ?? calendarZone; + const startInstant = startSame ? startInstantOf(event!, calendarZone) : fromDisplayDate(times.start); + const endInstant = endSame ? endInstantOf(event!, calendarZone) : fromDisplayDate(times.end); + if (startSame) { + // Only the end moved: the start (and a floating start's missing zone) + // stays as stored. + return { + start: event!.start, + duration: durationBetween(startInstant, endInstant, event!.timeZone || calendarZone), + timeZone: event!.timeZone, + showWithoutTime, + }; + } + return { + start: formatWallClock(startInstant, zone), + duration: durationBetween(startInstant, endInstant, zone), + timeZone: zone, + showWithoutTime, + }; +} diff --git a/src/lib/task-editor.ts b/src/lib/task-editor.ts index f5ac0391..8933c8a0 100644 --- a/src/lib/task-editor.ts +++ b/src/lib/task-editor.ts @@ -1,7 +1,12 @@ import { format } from 'date-fns'; import type { CalendarEvent } from '../api/types'; -import { getTaskDueDate } from './calendar-utils'; -import { getEffectiveTimeZone } from './calendar-timezone'; +import { getTaskDueDisplayDate, isDateOnlyDue } from './calendar-utils'; +import { + formatWallClock, + fromDisplayDate, + getEffectiveTimeZone, + isValidTimeZone, +} from './calendar-timezone'; export type PriorityLevel = 'none' | 'high' | 'medium' | 'low'; @@ -39,40 +44,64 @@ export function emptyTaskEditor(calendarId: string): TaskEditorState { return { id: null, title: '', description: '', due: null, withTime: false, priority: 'none', calendarId }; } -export function taskEditorFromTask(task: CalendarEvent, fallbackCalendarId: string): TaskEditorState { +export function taskEditorFromTask( + task: Pick, + fallbackCalendarId: string, +): TaskEditorState { return { id: task.id, title: task.title || '', description: task.description || '', - due: getTaskDueDate(task), - withTime: !!task.due && !task.showWithoutTime && !/^\d{4}-\d{2}-\d{2}$/.test(task.due), + // In the calendar's time zone, like events (see getTaskDueDisplayDate). + due: getTaskDueDisplayDate(task), + withTime: !!task.due && !isDateOnlyDue(task), priority: priorityToLevel(task.priority), calendarId: Object.keys(task.calendarIds || {})[0] || fallbackCalendarId, }; } +type ExistingTask = Pick + & Partial>; + +/** Did the editor leave the due of `existing` as it was shown? */ +function dueUnchanged(editor: TaskEditorState, existing: ExistingTask | undefined): boolean { + if (!existing?.due || !editor.due) return false; + const shown = getTaskDueDisplayDate(existing as Pick); + return !!shown + && shown.getTime() === editor.due.getTime() + && editor.withTime === !isDateOnlyDue(existing); +} + /** * The task properties the editor writes. `existing` is the task being * edited (undefined for a new one); a changed calendar is only sent when it - * differs from the one the task is in. + * differs from the one the task is in. A due the user left alone is not + * sent at all, so saving never moves it; a changed one is converted from the + * calendar's zone into the zone the task lives in. */ export function buildTaskEditorChanges( editor: TaskEditorState, - existing?: Pick, + existing?: ExistingTask, ): Partial { const data: Partial = { title: editor.title.trim(), description: editor.description.trim(), priority: levelToPriority(editor.priority), }; - if (editor.due) { + if (editor.id && dueUnchanged(editor, existing)) { + // Keep the stored due, zone and all-day flag. + } else if (editor.due) { if (editor.withTime) { - data.due = format(editor.due, "yyyy-MM-dd'T'HH:mm:ss"); + // The editor shows the due as a wall clock in the calendar's zone. + // Keep a timed task in its own zone; a new, floating or date-only one + // takes the calendar's. + const ownZone = existing?.due && !isDateOnlyDue(existing) && isValidTimeZone(existing.timeZone) + ? existing.timeZone + : null; + const zone = ownZone ?? getEffectiveTimeZone(); + data.due = formatWallClock(fromDisplayDate(editor.due), zone); data.showWithoutTime = false; - // The editor shows the due in local time; label it with the zone - // it was entered in, like the event editor, or the task's old zone - // would shift it. - data.timeZone = getEffectiveTimeZone(); + data.timeZone = zone; } else { data.due = format(editor.due, "yyyy-MM-dd'T'00:00:00"); data.showWithoutTime = true; diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx index af9c829e..9817d582 100644 --- a/src/screens/CalendarScreen.tsx +++ b/src/screens/CalendarScreen.tsx @@ -76,6 +76,7 @@ import { } from '../lib/calendar-scroll-window'; import { coversRange, createRangeLoader } from '../lib/calendar-range-cache'; import { + addDaysToLocalDateTime, applySharedCalendarColors, buildEventDayIndex, dayKey, @@ -794,13 +795,11 @@ export default function CalendarScreen() { const handleDuplicateFromDetail = React.useCallback( async (event: CalendarEvent) => { setDetailEvent(null); - const newStart = addDays(getEventStartDate(event), 1); const data: Partial = { title: event.title, description: event.description, - start: event.showWithoutTime - ? format(newStart, "yyyy-MM-dd'T'00:00:00") - : format(newStart, "yyyy-MM-dd'T'HH:mm:ss"), + // A day later at the same wall clock in the event's own zone. + start: addDaysToLocalDateTime(event.start, 1), duration: event.duration, timeZone: event.timeZone, showWithoutTime: event.showWithoutTime,