diff --git a/apps/api/src/auth/user.ts b/apps/api/src/auth/user.ts index 3a0339f..af611ca 100644 --- a/apps/api/src/auth/user.ts +++ b/apps/api/src/auth/user.ts @@ -13,17 +13,22 @@ import { db } from '../db/client.js' import { users } from '../db/schema.js' /** - * Accessible, visually-distinct palette for per-member color assignment. - * Assigned round-robin by join order (COUNT of existing users at insert time). + * Accessible, visually-distinct palette for per-member member-color assignment. + * A new member is given the first entry not already in use (see upsertUser). + * + * Ordering matters: the SHARED-family calendar is reserved rose (#F25C7A, D-06), + * so the warm near-rose hues (coral, amber) are placed LAST. Early members get + * cool colors (blue, green, teal) that read clearly distinct from the shared + * lane — otherwise a member's coral was mistaken for the shared rose. * Values are Claude's choice per D-06. */ export const COLOR_PALETTE: string[] = [ '#4A90D9', // calm blue - '#E8734A', // warm coral '#5BA85A', // forest green - '#9B6DC5', // soft purple - '#E8A840', // warm amber '#3AAFA9', // teal + '#9B6DC5', // soft purple + '#E8A840', // warm amber (near shared rose — assigned only after cool colors) + '#E8734A', // warm coral (closest to shared rose — assigned last) ] /** Coerce an OIDC claim to a trimmed non-empty string, else undefined. */ diff --git a/apps/pwa/src/lib/hydrateEvents.test.ts b/apps/pwa/src/lib/hydrateEvents.test.ts index 8480d86..f989496 100644 --- a/apps/pwa/src/lib/hydrateEvents.test.ts +++ b/apps/pwa/src/lib/hydrateEvents.test.ts @@ -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({ diff --git a/apps/pwa/src/lib/hydrateEvents.ts b/apps/pwa/src/lib/hydrateEvents.ts index 8ceda35..2558b16 100644 --- a/apps/pwa/src/lib/hydrateEvents.ts +++ b/apps/pwa/src/lib/hydrateEvents.ts @@ -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,