diff --git a/src/components/calendar/EventModal.tsx b/src/components/calendar/EventModal.tsx index 9dd5175f..802f83f1 100644 --- a/src/components/calendar/EventModal.tsx +++ b/src/components/calendar/EventModal.tsx @@ -82,7 +82,7 @@ import { SafeAreaModal } from '../SafeAreaModal'; import { useKeyboardObstruction } from '../../lib/use-keyboard-obstruction'; import { editorKeyboardInset } from '../../lib/calendar-editor-keyboard'; import ToggleSwitch from '../ToggleSwitch'; -import { askBeforeDiscard, editorFormChanged, editorFormSnapshot } from '../../lib/calendar-editor-discard'; +import { askBeforeDiscard, editorFormChanged, editorFormSnapshot, editorTimesSnapshot } from '../../lib/calendar-editor-discard'; import { saveOfferingWithoutInvitations } from '../../lib/calendar-scheduling-denied'; type RecurrenceOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom'; @@ -269,7 +269,7 @@ export function EventModal({ // What the form holds against what it opened with: Close and Back ask // before they drop changes (webmail 13f9f27c4). const formSnapshot = React.useMemo(() => editorFormSnapshot({ - title, description, allDay, start, end, calendarId, attendees, recurrence, customRule, + title, description, times: editorTimesSnapshot({ allDay, start, end }), calendarId, attendees, recurrence, customRule, reminders, useDefaults, preserved, sendInvitations, location, videoUrl, }), [title, description, allDay, start, end, calendarId, attendees, recurrence, customRule, reminders, useDefaults, preserved, sendInvitations, location, videoUrl]); diff --git a/src/lib/__tests__/calendar-editor-discard.test.ts b/src/lib/__tests__/calendar-editor-discard.test.ts index 8c2e5292..85e5fa51 100644 --- a/src/lib/__tests__/calendar-editor-discard.test.ts +++ b/src/lib/__tests__/calendar-editor-discard.test.ts @@ -3,8 +3,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; const alert = vi.fn(); vi.mock('react-native', () => ({ Alert: { alert: (...args: unknown[]) => alert(...args) } })); -import { askBeforeDiscard, editorFormChanged, editorFormSnapshot } from '../calendar-editor-discard'; +import { askBeforeDiscard, editorFormChanged, editorFormSnapshot, editorTimesSnapshot } from '../calendar-editor-discard'; import { emptyTaskEditor, taskEditorChanged, taskEditorFromTask } from '../task-editor'; +import { editorTimesFromEvent } from '../event-editor-times'; +import type { CalendarEvent } from '../../api/types'; +import fs from 'node:fs'; +import path from 'node:path'; const t = (_key: string, fallback?: string) => fallback ?? ''; @@ -13,6 +17,48 @@ const buttons = (): Button[] => alert.mock.calls.at(-1)![2] as Button[]; beforeEach(() => alert.mockReset()); +describe('the event editor times in the dirty check', () => { + // What the editor does with an all-day event it opens: seed the times, + // then its all-day effect sets start and end to midnight. + const opened = (event: Partial) => { + const seeded = editorTimesFromEvent(event as CalendarEvent); + const s = new Date(seeded.start); s.setHours(0, 0, 0, 0); + const e = new Date(seeded.end); e.setHours(0, 0, 0, 0); + if (e < s) e.setTime(s.getTime()); + return { seeded, normalized: { allDay: seeded.allDay, start: s, end: e } }; + }; + const snap = (times: { allDay: boolean; start: Date; end: Date }) => + editorFormSnapshot({ title: 'Company holiday', times: editorTimesSnapshot(times) }); + + it('finds no change in an all-day event opened and left alone', () => { + // "Company holiday" as Stalwart 0.16 returns it: a day, P1D. + const { seeded, normalized } = opened({ + '@type': 'Event', showWithoutTime: true, start: '2026-10-03T00:00:00', duration: 'P1D', + } as Partial); + expect(seeded.end.getHours()).toBe(23); // the inclusive last day, 23:59:59.999 + expect(editorFormChanged(snap(seeded), snap(normalized))).toBe(false); + // Two days, and one with a time zone attached. + const two = opened({ showWithoutTime: true, start: '2026-10-03T00:00:00', duration: 'P2D' }); + expect(editorFormChanged(snap(two.seeded), snap(two.normalized))).toBe(false); + const zoned = opened({ showWithoutTime: true, start: '2026-10-03T00:00:00', duration: 'P1D', timeZone: 'Europe/Berlin' }); + expect(editorFormChanged(snap(zoned.seeded), snap(zoned.normalized))).toBe(false); + }); + + it('still sees another day, and a moved timed event', () => { + const { normalized } = opened({ showWithoutTime: true, start: '2026-10-03T00:00:00', duration: 'P1D' }); + const nextDay = { ...normalized, end: new Date(normalized.end.getTime() + 86_400_000) }; + expect(editorFormChanged(snap(normalized), snap(nextDay))).toBe(true); + const timed = { allDay: false, start: new Date(2026, 9, 3, 9), end: new Date(2026, 9, 3, 10) }; + expect(editorFormChanged(snap(timed), snap({ ...timed, end: new Date(2026, 9, 3, 10, 30) }))).toBe(true); + expect(editorFormChanged(snap(timed), snap({ ...timed, allDay: true }))).toBe(true); + }); + + it('is what the event editor compares', () => { + const src = fs.readFileSync(path.resolve(__dirname, '../../components/calendar/EventModal.tsx'), 'utf8'); + expect(src).toMatch(/times: editorTimesSnapshot\(\{ allDay, start, end \}\)/); + }); +}); + describe('askBeforeDiscard', () => { it('closes at once when nothing was changed', () => { const close = vi.fn(); diff --git a/src/lib/calendar-editor-discard.ts b/src/lib/calendar-editor-discard.ts index f824a81b..eb5f7f99 100644 --- a/src/lib/calendar-editor-discard.ts +++ b/src/lib/calendar-editor-discard.ts @@ -11,6 +11,23 @@ export function editorFormSnapshot(fields: Record): string { return JSON.stringify(fields); } +/** + * The editor's times as the dirty check compares them. An all-day event + * compares by its first and last day: the editor seeds its last day as + * 23:59:59.999 (the exclusive end minus 1 ms) and then sets both to + * midnight, so comparing instants asked "Discard changes?" for an all-day + * event opened and closed untouched. A timed one compares by the instant. + */ +export function editorTimesSnapshot(times: { allDay: boolean; start: Date; end: Date }): Record { + if (!times.allDay) return { allDay: false, start: times.start, end: times.end }; + return { allDay: true, start: dayKey(times.start), end: dayKey(times.end) }; +} + +function dayKey(date: Date): string { + const pad = (n: number) => String(n).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; +} + /** Whether the form holds changes against `baseline`, the form it opened with (null: not seeded yet). */ export function editorFormChanged(baseline: string | null, current: string): boolean { return baseline !== null && baseline !== current;