Pre-capture all currently-cached rows into pendingDeleteRows before the whole-cache
db.delete() when seenUids.length === 0. The existing >0 branch behavior is unchanged.
Adds a regression test verifying onChanges receives one delete change per cached row
on a full-calendar clear.
- Create eventChangeDispatcher.ts: dispatchEventChange + isMeaningfulChange
- D-04: description-only edits are silent; meaningful fields = title/dtstartUtc/dtstartDate/allDay/location
- D-03: actor excluded via ne() + application-level filter; all subs filtered by userId != actorUserId
- D-13: reads only push_subscriptions from MariaDB — no tsdav/Fastmail I/O
- syncCalendar: add optional onChanges callback; populate title from VEVENT SUMMARY on every upsert
- syncCalendar: pre-upsert SELECT to detect add vs update; track changedFields; prune emits deletes
- poller: pass onChanges with actor=cred.userId (external changes from other member)
- outboxWorker.triggerTargetedResync: pass onChanges with actor=userId (this-member writes)
- All 4 eventChangeDispatcher tests + 14 sync tests GREEN
- Create apps/api/src/broker/reminderScheduler.ts:
- runReminderCheck(now): queries isShared=true + allDay=false events
with dtstartUtc in [now+14min, now+16min] via calendarEvents→calendars→
pushSubscriptions cross-join (2 innerJoins; fans out to all subscribers)
- In-memory sentReminders Set keyed uid:minuteBucket prevents double-fire
at window boundary (D-06, T-05-18)
- Per-event and per-subscription try/catch for error isolation (T-05-19)
- Null title fallback (event.title ?? uid) — handles rows before Plan 05-07
- Empty shared-calendar set produces zero sends and no crash (D-16)
- startReminderScheduler(): node-cron 1-min schedule wrapping runReminderCheck
- Wire startReminderScheduler() into index.ts isMainModule() guard after
startOutboxWorker() and VAPID setVapidDetails (NOTIF-01)
- Tests: 3/3 GREEN (all-day excluded, non-shared excluded, dedup)
- notifyListChange(listId, actorId, windowMs?) wraps coalesceListPush with a
dispatch closure that resolves actor name + list name from DB, builds
audience as owner ∪ list_shares MINUS actorId (D-03), and calls
dispatchPush per accessible subscriber subscription
- D-02 generic copy: '{Actor} made {N} changes to {ListName}' — no item text
- D-03 self-suppression: actorId filtered from audience before subscription load
- T-05-14: audience strictly scoped to list access (owner + list_shares only)
- T-05-15: no item text in notification body
- Empty audience and missing subscriptions are silent no-ops
- Tests: 5/5 GREEN (burst→1 push, self-suppress, access scope, empty audience)
- Bad/truncated VAPID_PRIVATE_KEY logs a warning instead of crashing the server
- API still starts and serves all other routes; push dispatch fails with a log message
- Create apps/api/src/routes/push.ts: GET /vapid-public-key, POST /subscription (upsert), DELETE /subscription (user-scoped)
- Wire pushRouter at /api/push in index.ts
- Call webpush.setVapidDetails() in isMainModule() guard before serve()
- Fix broken vi.getMockImplementation scaffold bug in push.test.ts (Rule 1)
- push.test.ts: all 4 tests GREEN
- module-level Map<string, {count, timer}> keyed by ${listId}:${actorId}
- sliding window: each call within window resets timer and increments count
- fires dispatch(listId, actorId, count) once on timer expiry; map entry self-deletes
- actorId passed as second arg so caller can apply excludeUserId=actorId (D-03)
- default windowMs=45000; injected dispatch keeps module pure and testable
- schema.ts: new pushSubscriptions mysqlTable (user_id FK cascade, endpoint unique, p256dh, auth)
- schema.ts: add nullable title varchar(500) to calendarEvents after rawVevent (D-02/NOTIF-01)
- 0003_same_xavin.sql: CREATE TABLE push_subscriptions + ALTER calendar_events ADD title
- migration applied to dev DB via db:generate + db:migrate (NOT db:push per anti-pattern)
- docker-compose.yml: inject VAPID_PUBLIC_KEY/PRIVATE_KEY/SUBJECT into api environment block
- .env.example: document all three VAPID vars with placeholders + generation instructions
The 0002_yielding_mattie_franklin.sql migration was committed but its
drizzle-kit journal entry and snapshot were left untracked. Without these,
drizzle-kit cannot track the migration as applied.
- Immediately after access check, return 403 if patch.isShared !== undefined
and !access.isOwner — blocks sharees from mutating list_shares
- Guard message: 'Only the list owner can change sharing settings'
- Sharees may still PATCH { name } (rename test stays green)
- Update stale comment: 'Reconcile list_shares on visibility change (owner only)'
- Closes T-04-08 (elevation of privilege) and T-04-05 (shared root cause)
src/lib/rank.test.ts was the last co-located API test. The API tsconfig excludes
tests/ from the build, so all test files belong there; a test in src/ gets compiled
into dist/ and vitest then runs the stale compiled copy (the source of phantom
'22 todo' and dist sourcemap warnings). Moving it to tests/lib/ matches convention
and leaves zero test files in src/, so the production build no longer emits test
artifacts. Updated rank.test.ts path refs in phase-04 plan docs.
- Wire publishListEvent fan-out in lists.ts after every write mutation (item:added/updated/deleted, list:updated/deleted)
- Add GET /api/sse/lists scoped endpoint in sse.ts: resolveUserId → 401 on null; getAccessibleListIds → subscribe only to accessible channels; 30s heartbeat; cleanup on disconnect (D-04/T-04-01/T-04-02)
- Create useListSSE.ts: bounded-backoff EventSource wrapper (250ms→500ms→1s→2s→4s→cap 8s); MAX_ATTEMPTS=6; withCredentials:true; close-before-retry prevents reconnect storm (Pitfall 3); invalidates ['list', listId] on open (D-10) and on each event; onStateChange('disconnected') after exhaustion (D-11)
- Create LiveSyncIndicator.tsx: connected=green dot; reconnecting=pulsing muted dot + label; disconnected=red dot + 'Updates paused' (role=alert); correct ARIA per UI-SPEC
- Wire useListSSE + LiveSyncIndicator into ListDetail header; retain refetchInterval:30000 polling fallback (D-12)
- All 8 useListSSE tests pass; all 54 API tests pass; both typechecks pass
- playwright-cli: live update confirmed (eggs item added via API appeared in browser without manual refresh)
- rank.test.ts: 100-iteration zipper mid-point insert precision test (Pitfall 2);
rank-between-neighbors contract test; total 10 tests (was 8)
- lists.test.ts: 5 new LIST-03 ordering tests — PATCH position updates only rank
and GET returns new ASC order; one-row write asserts other items unchanged;
LWW (D-15): second PATCH overwrites first; T-04-07 two-field position PATCH → 400
- Note: tests use a0–a5 rank range (avoids uppercase ranks that sort differently
under MariaDB utf8mb4_unicode_ci vs JS lexicographic order)
- GET /: scoped access (owner + list_shares); activeCount/doneCount per list
- POST /: auto-populates list_shares for all other members when isShared=true (D-01/D-02)
- PATCH /🆔 rename + isShared toggle; reconciles list_shares on visibility change
- DELETE /🆔 owner-only; cascade handles items/shares via FK onDelete cascade
- resolveUserId helper copied verbatim from events.ts per project convention
- zod createListSchema (name 1..255, isShared default true) + patchListSchema
- T-04-02 / T-04-05 / T-04-07 / T-04-08 mitigations applied
- listsRouter mounted at /api/lists in index.ts (after sseRouter)
- Plan 06 SSE seam comments left at every mutation handler
- [Rule 1 - Fix] zValidator returns 400 (not 422); tests corrected to match convention
- All 23 tests green; full API suite 140 passed no regressions
listEmitter.ts:
- Module-level EventEmitter singleton; setMaxListeners(200) (T-04-04)
- publishListEvent(listId, event): emits on list:${listId} channel
- subscribeListEvents(listId, handler): registers listener, returns unsub closure
- ListEvent type union: item:added/updated/deleted, list:updated/deleted
- D-04 isolation guaranteed by per-list channel keying
listAccess.ts:
- getAccessibleListIds(userId): two SELECT queries (owned + shared), Set dedupe
- Satisfies T-04-02/T-04-03: over-returning proven impossible by Test 7
listAccess.test.ts fix:
- Use randomUUID() suffix in seedUser to avoid oidc_sub unique-key collisions
across test re-runs (users table not truncated by global afterEach)
vitest.config.ts:
- pool: 'forks' + singleFork: true to prevent FK violations from concurrent
DB workers racing against the shared-state global afterEach cleanup
- sequence.concurrent: false as belt-and-suspenders
ioredis NOT introduced (D-18 abstraction boundary satisfied)
The two Wave-0 RED stubs (lists.test.ts, listEmitter.test.ts) were co-located in
src/ but all existing API tests live in apps/api/tests/. Move them to tests/routes/
and tests/lib/, add explicit vitest imports to match the tests/ convention, and
update path references in downstream plans 04-02..04-06. PWA tests keep co-location
(that IS the PWA convention).
- Add react-router@7, @dnd-kit/core, @dnd-kit/sortable, fractional-indexing to PWA
- Add fractional-indexing to API (rank generation server-side)
- ioredis NOT added (in-memory EventEmitter per RESEARCH Plan 02 justification)
- Create apps/api/test/setup.ts with afterEach DB cleanup for list tables
- Wire test.setupFiles in apps/api/vitest.config.ts
- Add 4 Wave-0 RED stub test files (LIST-01/02/03/04, D-04, D-11, D-07)
- All stubs run as todo, not import-error
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 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).
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.