fix: close an untouched all-day event without asking to discard changes

This commit is contained in:
Linus Rath
2026-10-31 20:14:38 +01:00
parent 818c45c280
commit 305b836001
3 changed files with 66 additions and 3 deletions
+2 -2
View File
@@ -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]);
@@ -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<CalendarEvent>) => {
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<CalendarEvent>);
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();
+17
View File
@@ -11,6 +11,23 @@ export function editorFormSnapshot(fields: Record<string, unknown>): 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<string, unknown> {
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;