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,