fix(calendar): all-day off-by-one (exclusive DTEND) + member color too close to shared rose

All-day: a single-day all-day event displayed across two days. iCal all-day
DTEND is EXCLUSIVE (1-day event = DTSTART:24/DTEND:25) and the server occurrence
carries that exclusive end, but Schedule-X treats all-day end as INCLUSIVE.
hydrateEvents now subtracts one day (clamped to >= start) so a 1-day event shows
on one day and an N-day event spans N days. Write path was already correct
(verified against stored VEVENTs). +regression test.

Color: a member's coral (#E8734A) was mistaken for the shared-family rose
(#F25C7A). Reorder COLOR_PALETTE so warm near-rose hues (amber, coral) are
assigned LAST; early members get cool, clearly-distinct colors (blue/green/teal).
This commit is contained in:
Lucas Berger
2026-06-07 18:37:56 -04:00
parent 29b8c02715
commit d4d5327fc4
3 changed files with 42 additions and 7 deletions
+17
View File
@@ -76,6 +76,23 @@ describe('hydrateEvents — RED stubs (Wave 0)', () => {
expect(evt.end).toBeInstanceOf(Temporal.PlainDate)
})
it('converts an all-day exclusive DTEND to an inclusive last day for Schedule-X', () => {
// Single-day event: iCal DTSTART:24 / DTEND:25 (exclusive). Schedule-X end is
// inclusive, so a 1-day event must have start === end (renders on one day only).
const single = hydrateEvents([
makeOccurrence({ allDay: true, start: '2026-06-24', end: '2026-06-25' }),
])[0]
expect((single.start as Temporal.PlainDate).toString()).toBe('2026-06-24')
expect((single.end as Temporal.PlainDate).toString()).toBe('2026-06-24')
// Two-day event: DTSTART:26 / DTEND:28 (exclusive) → inclusive last day = 27.
const multi = hydrateEvents([
makeOccurrence({ allDay: true, start: '2026-06-26', end: '2026-06-28' }),
])[0]
expect((multi.start as Temporal.PlainDate).toString()).toBe('2026-06-26')
expect((multi.end as Temporal.PlainDate).toString()).toBe('2026-06-27')
})
it('converts timed occurrence (allDay:false) to Temporal.ZonedDateTime for start and end', () => {
const occurrences = [
makeOccurrence({
+15 -2
View File
@@ -76,11 +76,24 @@ export function hydrateEvents(occurrences: CalendarOccurrence[]): ScheduleXEvent
if (occ.allDay) {
// All-day: use Temporal.PlainDate — do NOT construct ZonedDateTime from
// midnight UTC. occ.start and occ.end are 'YYYY-MM-DD' strings.
//
// Exclusive→inclusive end conversion: iCalendar all-day DTEND is EXCLUSIVE
// (a single-day event on the 24th is DTSTART:24 / DTEND:25), and the server
// occurrence carries that exclusive end. Schedule-X treats an all-day event's
// `end` as INCLUSIVE (the last day it covers), so passing the exclusive DTEND
// straight through renders every all-day event one day too long (a 1-day event
// showed across two days). Subtract one day to get the inclusive last day,
// clamped to never precede start.
const startPd = Temporal.PlainDate.from(occ.start)
const endExclusive = Temporal.PlainDate.from(occ.end)
const endInclusive = endExclusive.subtract({ days: 1 })
const end =
Temporal.PlainDate.compare(endInclusive, startPd) < 0 ? startPd : endInclusive
return {
id: occ.id,
title: occ.title,
start: Temporal.PlainDate.from(occ.start),
end: Temporal.PlainDate.from(occ.end),
start: startPd,
end,
calendarId,
_familySync: {
uid: occ.uid,