feat(06-01): implement duration-preserving end-tracking helpers

- computeNewTimedEnd: preserves timed duration; 1h floor for stale state
- computeNewAllDayEnd: preserves all-day day-span; same-day floor for stale state
- Private helpers: dateDiffDays, addDaysISO, localDateISO, localTimeHHMM (local accessors only — WR-05)
- All 6 RED tests now GREEN; pre-existing suite unaffected (166/166 pass)
This commit is contained in:
Lucas Berger
2026-06-10 10:49:10 -04:00
parent 16cdbf3d7c
commit 605f543f81
+88
View File
@@ -59,3 +59,91 @@ export function serializeEventDateTime(
export function localWallClockToUtcIso(date: string, time: string): string { export function localWallClockToUtcIso(date: string, time: string): string {
return new Date(`${date}T${time}:00`).toISOString() 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)
}