diff --git a/apps/pwa/src/lib/eventDateTime.ts b/apps/pwa/src/lib/eventDateTime.ts index d499be3..5122299 100644 --- a/apps/pwa/src/lib/eventDateTime.ts +++ b/apps/pwa/src/lib/eventDateTime.ts @@ -59,3 +59,91 @@ export function serializeEventDateTime( export function localWallClockToUtcIso(date: string, time: string): string { return new Date(`${date}T${time}:00`).toISOString() } + +// ─── Private date helpers (local accessor pattern — WR-05 constraint) ──────── +// NEVER use toISOString().slice(0,10) — that returns the UTC date, not the +// local date. Always use getFullYear/getMonth/getDate/getHours/getMinutes. + +function pad2(n: number): string { + return n < 10 ? `0${n}` : `${n}` +} + +/** Format a Date as 'YYYY-MM-DD' using LOCAL calendar accessors. */ +function localDateISO(d: Date): string { + return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}` +} + +/** Format a Date as 'HH:MM' using LOCAL clock accessors. */ +function localTimeHHMM(d: Date): string { + return `${pad2(d.getHours())}:${pad2(d.getMinutes())}` +} + +/** + * Return the number of whole calendar days between two 'YYYY-MM-DD' strings. + * Positive when end > start, negative when end < start. + * Uses midnight-local Date arithmetic to stay in the local calendar. + */ +function dateDiffDays(startDate: string, endDate: string): number { + const startMs = new Date(`${startDate}T00:00:00`).getTime() + const endMs = new Date(`${endDate}T00:00:00`).getTime() + return Math.round((endMs - startMs) / (24 * 60 * 60 * 1000)) +} + +/** + * Add a whole number of days to a 'YYYY-MM-DD' string, returning a new + * 'YYYY-MM-DD' string. Uses local midnight to avoid DST-boundary issues. + */ +function addDaysISO(dateStr: string, days: number): string { + const d = new Date(`${dateStr}T00:00:00`) + d.setDate(d.getDate() + days) + return localDateISO(d) +} + +// ─── D-04: Duration-preservation helpers ───────────────────────────────────── + +/** + * Compute a new timed event end when the start changes, preserving duration. + * + * D-04 contract: + * - newEnd = newStart + (oldEnd − oldStart) + * - Floor: if oldEnd ≤ oldStart (stale/invalid), newEnd = newStart + 1h + * + * Returns { endDate: 'YYYY-MM-DD', endTime: 'HH:MM' } using LOCAL accessors + * (WR-05: never UTC-sliced). + */ +export function computeNewTimedEnd( + newStartDate: string, + newStartTime: string, + oldStartDate: string, + oldStartTime: string, + oldEndDate: string, + oldEndTime: string, +): { endDate: string; endTime: string } { + const oldStartMs = new Date(`${oldStartDate}T${oldStartTime}:00`).getTime() + const oldEndMs = new Date(`${oldEndDate}T${oldEndTime}:00`).getTime() + const deltaMs = oldEndMs > oldStartMs ? oldEndMs - oldStartMs : 60 * 60 * 1000 // 1h floor + const newEndDate = new Date(new Date(`${newStartDate}T${newStartTime}:00`).getTime() + deltaMs) + return { + endDate: localDateISO(newEndDate), + endTime: localTimeHHMM(newEndDate), + } +} + +/** + * Compute a new all-day event end when the start date changes, preserving + * the inclusive day-span. + * + * D-04 contract: + * - span = max(0, oldEnd − oldStart) days (Math.max floors negative spans to 0) + * - newEnd = newStart + span + * + * Returns 'YYYY-MM-DD' (inclusive end date). + */ +export function computeNewAllDayEnd( + newStartDate: string, + oldStartDate: string, + oldEndDate: string, +): string { + const span = Math.max(0, dateDiffDays(oldStartDate, oldEndDate)) + return addDaysISO(newStartDate, span) +}