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:
@@ -13,17 +13,22 @@ import { db } from '../db/client.js'
|
|||||||
import { users } from '../db/schema.js'
|
import { users } from '../db/schema.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Accessible, visually-distinct palette for per-member color assignment.
|
* Accessible, visually-distinct palette for per-member member-color assignment.
|
||||||
* Assigned round-robin by join order (COUNT of existing users at insert time).
|
* 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.
|
* Values are Claude's choice per D-06.
|
||||||
*/
|
*/
|
||||||
export const COLOR_PALETTE: string[] = [
|
export const COLOR_PALETTE: string[] = [
|
||||||
'#4A90D9', // calm blue
|
'#4A90D9', // calm blue
|
||||||
'#E8734A', // warm coral
|
|
||||||
'#5BA85A', // forest green
|
'#5BA85A', // forest green
|
||||||
'#9B6DC5', // soft purple
|
|
||||||
'#E8A840', // warm amber
|
|
||||||
'#3AAFA9', // teal
|
'#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. */
|
/** Coerce an OIDC claim to a trimmed non-empty string, else undefined. */
|
||||||
|
|||||||
@@ -76,6 +76,23 @@ describe('hydrateEvents — RED stubs (Wave 0)', () => {
|
|||||||
expect(evt.end).toBeInstanceOf(Temporal.PlainDate)
|
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', () => {
|
it('converts timed occurrence (allDay:false) to Temporal.ZonedDateTime for start and end', () => {
|
||||||
const occurrences = [
|
const occurrences = [
|
||||||
makeOccurrence({
|
makeOccurrence({
|
||||||
|
|||||||
@@ -76,11 +76,24 @@ export function hydrateEvents(occurrences: CalendarOccurrence[]): ScheduleXEvent
|
|||||||
if (occ.allDay) {
|
if (occ.allDay) {
|
||||||
// All-day: use Temporal.PlainDate — do NOT construct ZonedDateTime from
|
// All-day: use Temporal.PlainDate — do NOT construct ZonedDateTime from
|
||||||
// midnight UTC. occ.start and occ.end are 'YYYY-MM-DD' strings.
|
// 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 {
|
return {
|
||||||
id: occ.id,
|
id: occ.id,
|
||||||
title: occ.title,
|
title: occ.title,
|
||||||
start: Temporal.PlainDate.from(occ.start),
|
start: startPd,
|
||||||
end: Temporal.PlainDate.from(occ.end),
|
end,
|
||||||
calendarId,
|
calendarId,
|
||||||
_familySync: {
|
_familySync: {
|
||||||
uid: occ.uid,
|
uid: occ.uid,
|
||||||
|
|||||||
Reference in New Issue
Block a user