The CR-01 fix appended .orderBy().limit(1) to the edit/delete event lookups
and CR-02 added .innerJoin(calendars).limit(1) to the freshest-etag re-read.
The existing test doubles terminated the mock chain at .where(), so the new
chain calls hit undefined methods → handlers caught the throw and returned 503
(events.test.ts) and the worker skipped the PUT (outboxWorker.test.ts).
Extend the mocks to match the corrected production chains. Behaviour-preserving:
mockWhereCalEvents stays the awaited terminal so etag override assertions still drive.
8 failing tests now green; full suite: api 103, pwa 141.
phase.add appended the entry after the 999.x backlog (the decimal backlog
convention confused the auto-placement). Moved it into Phase Details before
## Progress, added it to the top checklist + Progress table + execution order,
gave it goal/success-criteria/candidate-scope (promotes backlog 999.2/3/6/7/8/9),
renamed the dir to 06-ux-polish, and bumped STATE to 6 phases (50%).
Weekly recurring create writes valid RRULE and recurred in Fastmail; repeat-bound
+ per-occurrence-duration UX gaps backlogged (999.7/999.8). Deleting the recurring
series cleared master + all occurrences in one delete. Remaining deferred by
design: B5 (Android), C (SSE smoke — Phase 4 entry gate).
Captured from Gate 2 live testing 2026-06-07:
- 999.6 all-day events need distinct visual treatment
- 999.7 event form: auto-advance end when start moves; + latent all-day EDIT
off-by-one (edit grows the event by a day — write/display convert inclusive
<-> exclusive but the edit form does not)
- 999.8 recurrence bound (repeat-until/count) so a recurring event isn't one
giant multi-month event; verify daily-vs-weekly selection
- 999.9 edit a recurring series (whole-series edit; per-occurrence already v1.x)
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).
Gate 2 A3 fail: a second member (amelia) got the same color as the first (luc),
both #E8734A. Color was assigned by COUNT(*) % palette; a deleted spike user
shifted the count so two live members landed on the same slot. Replace with
'first palette color not already in use by another user' (fall back to count
round-robin only once the palette is exhausted) — guarantees distinct, stable
colors for up to palette length members. +1 regression test (deletion frees a
slot → next member fills it, no collision).
The calendar flashed whenever the event popup/form closed or a post-write events
refetch landed. Root cause: CalendarContent was a function component DEFINED
INSIDE CalendarShell's render and used as <CalendarContent />. A nested component
has a new identity every render, so React unmounted+remounted its whole subtree
— including <ScheduleXCalendar> — on ANY CalendarShell re-render. The earlier
Bug B work only minimized re-renders (Zustand selectors) to dodge this; the
resync-before-done fix made the post-write ['events'] refetch deliver changed
data again, so the remount/flash returned.
Fix: render the content as a plain JSX element value (const calendarContent)
referenced at both layout sites instead of a nested component type. Element
values reconcile in place across re-renders — no remount, no flash.
Captured from Gate 2 live testing: when the OIDC session expires mid-use, the
app hangs the action and shows a generic 'couldn't load events' instead of
recognizing the signed-out state and redirecting to /api/login. Re-auth is
currently only wired to the initial /api/me failure (one-shot).
The legend showed 'Member 972be1a3' because Authelia does not emit
name/preferred_username/email in the ID TOKEN (only at the userinfo endpoint),
and @hono/oidc-auth reads ID-token claims only. The real fix is an Authelia
claims_policy adding those claims to id_token for the familysync client.
App-side robustness so it self-heals once Authelia is fixed (no DB surgery):
- deriveDisplayName now returns null (not a synthetic 'Member <sub>') when no
real claim is present, so we never persist an ugly sub string; the UI degrades
to a generic 'Member'.
- upsertUser now tracks the IdP name authoritatively: a non-null displayName that
differs from the stored value updates the row (blank/stale 'Member …'/email →
real name on next login). A null value never overwrites a good stored name.
Two write-path cache bugs surfaced during Gate 2 live testing:
P1 (delete didn't work / ghost event): syncCalendar only UPSERTED events
present on Fastmail and never removed cache rows for events that disappeared.
A successful CalDAV delete left the row in calendar_events forever, so
GET /api/events kept returning it and the UI showed a ghost that 'wouldn't
delete' (even after refresh). Add a prune step: delete calendar_events rows for
this calendar whose uid is absent from the server response (scoped to cal.id so
it never touches another calendar or the other member's rows — BUG B). Empty
server result prunes the whole calendar's cache.
P2 (edit needed a manual refresh): the outbox worker marked a row 'done' BEFORE
triggerTargetedResync refreshed the cache. The PWA's SyncStateToast invalidates
['events'] the instant sync-status flips to 'done', so it refetched stale cache.
Re-sync first, then mark done — 'done' now guarantees the cache reflects the write.
Tests: +2 prune regressions (present-subset prune, empty-server prune-all).
Internal Server Error after returning from Authelia: processOAuthCallback threw
OAUTH_INVALID_RESPONSE ("unexpected state parameter") because the OIDC state
cookie no longer matched the state returned to /callback.
Root cause: eventsQuery (fetchEvents, redirect:'follow', retry:2) ran
concurrently with fetchMe on load. While unauthenticated, every /api/* request
hits the OIDC guard, which 302-redirects to Authelia AND sets a fresh state
cookie. fetchEvents could not follow the cross-origin redirect, so React Query
retried it up to 3x over ~3s — each retry overwriting the state cookie mid-login,
racing the single /api/login navigation that owns the real flow.
Fix: enabled: meQuery.isSuccess. Only fetchMe (redirect:'manual', retry:false)
touches a guarded endpoint while unauthenticated, so the top-level /api/login
navigation owns the state cookie uncontested. Realizes the documented design
intent that only fetchMe drives the login redirect.
The displayName claim-preference logic (name → preferred_username → email →
sub fallback) was duplicated verbatim in me.ts and events.ts resolveUserId.
Extract it to auth/user.ts as deriveDisplayName and use it in both call sites,
so the rule has one definition. Update the events.test.ts user.js mock to keep
the real helper (spread importActual) while stubbing only upsertUser.