From d521839a40daa1625bfd6f0b508ec8cb9a6dad93 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Tue, 9 Jun 2026 14:57:27 -0400 Subject: [PATCH] =?UTF-8?q?docs(intel):=20refresh=20codebase=20intelligenc?= =?UTF-8?q?e=20at=2099f59c3=20=E2=80=94=20capture=20Phase=204=20lists=20+?= =?UTF-8?q?=20live-sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stale intel reflected commit 01f7456 (pre-Phase-4). Refresh adds the shared-lists + SSE subsystem: 21 API endpoints (was 10), lists/sse routes, listEmitter/listAccess/rank libs, PWA lists surface, 7 new arch decisions, and Phase 4 deps (react-router, @dnd-kit, fractional-indexing). Canonical filenames preserved; API-SURFACE.md + .last-refresh.json regenerated. --- .planning/intel/API-SURFACE.md | 93 +++++- .planning/intel/api-map.json | 119 +++++++- .planning/intel/arch-decisions.json | 121 +++++++- .planning/intel/dependency-graph.json | 150 ++++++++-- .planning/intel/file-roles.json | 406 +++++++++++++++++++++----- .planning/intel/stack.json | 22 +- 6 files changed, 779 insertions(+), 132 deletions(-) diff --git a/.planning/intel/API-SURFACE.md b/.planning/intel/API-SURFACE.md index 288b09c..9f361d8 100644 --- a/.planning/intel/API-SURFACE.md +++ b/.planning/intel/API-SURFACE.md @@ -102,4 +102,95 @@ - **auth:** oidcAuthMiddleware - **file:** apps/api/src/routes/sse.ts - **response:** text/event-stream — event: heartbeat, data: { ts, id } every 10s -- **description:** SSE smoke-test endpoint for Pangolin tunnel validation. Phase 4 list-sync events not yet wired. +- **description:** SSE smoke-test endpoint for Pangolin tunnel validation. + +## `GET /api/sse/lists` + +- **method:** GET +- **path:** /api/sse/lists +- **auth:** oidcAuthMiddleware +- **file:** apps/api/src/routes/sse.ts +- **response:** text/event-stream — events: item:added | item:updated | item:deleted | list:updated | list:deleted, plus heartbeat every 30s +- **description:** Scoped live-list fan-out stream (LIST-04, D-04). Subscribes only to list channels accessible to the caller (owner + shares). Event payload triggers client-side query invalidation (D-10). 30s keepalive heartbeat for Pangolin tunnel. + +## `GET /api/lists` + +- **method:** GET +- **path:** /api/lists +- **auth:** oidcAuthMiddleware +- **file:** apps/api/src/routes/lists.ts +- **response:** { lists: [{ id, name, isShared, ownerId, activeCount, doneCount, createdAt, updatedAt }] } +- **description:** Scoped list index. Returns only lists the caller owns or has a list_shares row for (T-04-02, D-04). Includes per-list item counts. + +## `POST /api/lists` + +- **method:** POST +- **path:** /api/lists +- **auth:** oidcAuthMiddleware +- **body:** { name: string, isShared?: boolean (default true) } +- **file:** apps/api/src/routes/lists.ts +- **response:** 201 { id, name, isShared, ownerId, activeCount, doneCount, createdAt, updatedAt } +- **description:** Create a named list. isShared=true (default) auto-inserts list_shares for all other members (D-01/D-02). Shares are server-managed only — no client shares endpoint (T-04-08). + +## `PATCH /api/lists/:id` + +- **method:** PATCH +- **path:** /api/lists/:id +- **auth:** oidcAuthMiddleware +- **params:** id (path) +- **body:** { name?: string, isShared?: boolean } — at least one field required +- **file:** apps/api/src/routes/lists.ts +- **response:** { id, name, isShared, ownerId, createdAt, updatedAt } +- **description:** Update name and/or isShared. Caller must be owner or sharee. isShared mutations owner-only (T-04-07/T-04-08). Visibility change reconciles list_shares (false→true inserts; true→false deletes all non-owner shares). + +## `DELETE /api/lists/:id` + +- **method:** DELETE +- **path:** /api/lists/:id +- **auth:** oidcAuthMiddleware +- **params:** id (path) +- **file:** apps/api/src/routes/lists.ts +- **response:** { id } +- **description:** Owner-only delete. Cascade via FK onDelete:cascade removes items and shares. Non-owner sharees receive 403. + +## `GET /api/lists/:id/items` + +- **method:** GET +- **path:** /api/lists/:id/items +- **auth:** oidcAuthMiddleware +- **params:** id (path) +- **file:** apps/api/src/routes/lists.ts +- **response:** { items: [{ id, listId, text, checked, rank, createdAt, updatedAt }] } +- **description:** Returns all items for the list ordered by rank ASC. Access-gated: owner or sharee only (T-04-05). + +## `POST /api/lists/:id/items` + +- **method:** POST +- **path:** /api/lists/:id/items +- **auth:** oidcAuthMiddleware +- **params:** id (path) +- **body:** { text: string (1..500) } +- **file:** apps/api/src/routes/lists.ts +- **response:** 201 { id, listId, text, checked, rank, createdAt, updatedAt } +- **description:** Add item to list. Rank assigned via rankForAppend(lastActiveRank) — appends after last unchecked item. Access-gated (T-04-05). publishListEvent item:added fan-out. + +## `PATCH /api/list-items/:itemId` + +- **method:** PATCH +- **path:** /api/list-items/:itemId +- **auth:** oidcAuthMiddleware +- **params:** itemId (path) +- **body:** exactly one of: { checked: boolean } | { text: string } | { position: string } +- **file:** apps/api/src/routes/lists.ts +- **response:** { id, listId, text, checked, rank, createdAt, updatedAt } +- **description:** Per-field last-write-wins item update (D-08). Exactly one field enforced by Zod (T-04-07). uncheck (checked:false) recomputes rank to active-bottom. 404 if item missing (T-04-09, no upsert). publishListEvent item:updated fan-out. + +## `DELETE /api/list-items/:itemId` + +- **method:** DELETE +- **path:** /api/list-items/:itemId +- **auth:** oidcAuthMiddleware +- **params:** itemId (path) +- **file:** apps/api/src/routes/lists.ts +- **response:** { id } +- **description:** Delete item instantly (D-06). Delete-wins semantics (D-09): no rollback path. Access-gated: owner or sharee. publishListEvent item:deleted fan-out. diff --git a/.planning/intel/api-map.json b/.planning/intel/api-map.json index 661a14f..9db5988 100644 --- a/.planning/intel/api-map.json +++ b/.planning/intel/api-map.json @@ -1,8 +1,8 @@ { "_meta": { - "updated_at": "2026-06-09T00:00:00Z", - "commit": "01f7456b81dd55d477d0bd7530df818f61a873c2", - "version": 1 + "updated_at": "2026-06-09T18:56:37.459Z", + "commit": "99f59c3999f4ef992f01ef8f8a38c1d86d2f2a0f", + "version": 3 }, "entries": { "GET /health": { @@ -38,7 +38,10 @@ "method": "GET", "path": "/api/events", "auth": "oidcAuthMiddleware", - "params": ["start (YYYY-MM-DD, required)", "end (YYYY-MM-DD, required)"], + "params": [ + "start (YYYY-MM-DD, required)", + "end (YYYY-MM-DD, required)" + ], "file": "apps/api/src/routes/events.ts", "response": "{ occurrences: CalendarOccurrence[] }", "description": "Windowed calendar events. Max 90-day window. Reads only from MariaDB cache; never hits Fastmail. Expands RRULEs server-side." @@ -56,7 +59,9 @@ "method": "PATCH", "path": "/api/events/:uid/edit", "auth": "oidcAuthMiddleware", - "params": ["uid (path)"], + "params": [ + "uid (path)" + ], "body": "CreateEventPayload", "file": "apps/api/src/routes/events.ts", "response": "202 { uid: string }", @@ -66,7 +71,9 @@ "method": "DELETE", "path": "/api/events/:uid", "auth": "oidcAuthMiddleware", - "params": ["uid (path)"], + "params": [ + "uid (path)" + ], "file": "apps/api/src/routes/events.ts", "response": "202 { uid: string }", "description": "Enqueues delete to calendarOutbox with cached etag (If-Match). Async write-back." @@ -75,7 +82,9 @@ "method": "GET", "path": "/api/events/sync-status", "auth": "oidcAuthMiddleware", - "params": ["uid (query, required)"], + "params": [ + "uid (query, required)" + ], "file": "apps/api/src/routes/events.ts", "response": "{ uid: string, status: 'pending'|'done'|'failed'|'dead', error?: string }", "description": "Outbox status poll for a given event UID, scoped to current member. Used by SyncStateToast." @@ -94,7 +103,101 @@ "auth": "oidcAuthMiddleware", "file": "apps/api/src/routes/sse.ts", "response": "text/event-stream — event: heartbeat, data: { ts, id } every 10s", - "description": "SSE smoke-test endpoint for Pangolin tunnel validation. Phase 4 list-sync events not yet wired." + "description": "SSE smoke-test endpoint for Pangolin tunnel validation." + }, + "GET /api/sse/lists": { + "method": "GET", + "path": "/api/sse/lists", + "auth": "oidcAuthMiddleware", + "file": "apps/api/src/routes/sse.ts", + "response": "text/event-stream — events: item:added | item:updated | item:deleted | list:updated | list:deleted, plus heartbeat every 30s", + "description": "Scoped live-list fan-out stream (LIST-04, D-04). Subscribes only to list channels accessible to the caller (owner + shares). Event payload triggers client-side query invalidation (D-10). 30s keepalive heartbeat for Pangolin tunnel." + }, + "GET /api/lists": { + "method": "GET", + "path": "/api/lists", + "auth": "oidcAuthMiddleware", + "file": "apps/api/src/routes/lists.ts", + "response": "{ lists: [{ id, name, isShared, ownerId, activeCount, doneCount, createdAt, updatedAt }] }", + "description": "Scoped list index. Returns only lists the caller owns or has a list_shares row for (T-04-02, D-04). Includes per-list item counts." + }, + "POST /api/lists": { + "method": "POST", + "path": "/api/lists", + "auth": "oidcAuthMiddleware", + "body": "{ name: string, isShared?: boolean (default true) }", + "file": "apps/api/src/routes/lists.ts", + "response": "201 { id, name, isShared, ownerId, activeCount, doneCount, createdAt, updatedAt }", + "description": "Create a named list. isShared=true (default) auto-inserts list_shares for all other members (D-01/D-02). Shares are server-managed only — no client shares endpoint (T-04-08)." + }, + "PATCH /api/lists/:id": { + "method": "PATCH", + "path": "/api/lists/:id", + "auth": "oidcAuthMiddleware", + "params": [ + "id (path)" + ], + "body": "{ name?: string, isShared?: boolean } — at least one field required", + "file": "apps/api/src/routes/lists.ts", + "response": "{ id, name, isShared, ownerId, createdAt, updatedAt }", + "description": "Update name and/or isShared. Caller must be owner or sharee. isShared mutations owner-only (T-04-07/T-04-08). Visibility change reconciles list_shares (false→true inserts; true→false deletes all non-owner shares)." + }, + "DELETE /api/lists/:id": { + "method": "DELETE", + "path": "/api/lists/:id", + "auth": "oidcAuthMiddleware", + "params": [ + "id (path)" + ], + "file": "apps/api/src/routes/lists.ts", + "response": "{ id }", + "description": "Owner-only delete. Cascade via FK onDelete:cascade removes items and shares. Non-owner sharees receive 403." + }, + "GET /api/lists/:id/items": { + "method": "GET", + "path": "/api/lists/:id/items", + "auth": "oidcAuthMiddleware", + "params": [ + "id (path)" + ], + "file": "apps/api/src/routes/lists.ts", + "response": "{ items: [{ id, listId, text, checked, rank, createdAt, updatedAt }] }", + "description": "Returns all items for the list ordered by rank ASC. Access-gated: owner or sharee only (T-04-05)." + }, + "POST /api/lists/:id/items": { + "method": "POST", + "path": "/api/lists/:id/items", + "auth": "oidcAuthMiddleware", + "params": [ + "id (path)" + ], + "body": "{ text: string (1..500) }", + "file": "apps/api/src/routes/lists.ts", + "response": "201 { id, listId, text, checked, rank, createdAt, updatedAt }", + "description": "Add item to list. Rank assigned via rankForAppend(lastActiveRank) — appends after last unchecked item. Access-gated (T-04-05). publishListEvent item:added fan-out." + }, + "PATCH /api/list-items/:itemId": { + "method": "PATCH", + "path": "/api/list-items/:itemId", + "auth": "oidcAuthMiddleware", + "params": [ + "itemId (path)" + ], + "body": "exactly one of: { checked: boolean } | { text: string } | { position: string }", + "file": "apps/api/src/routes/lists.ts", + "response": "{ id, listId, text, checked, rank, createdAt, updatedAt }", + "description": "Per-field last-write-wins item update (D-08). Exactly one field enforced by Zod (T-04-07). uncheck (checked:false) recomputes rank to active-bottom. 404 if item missing (T-04-09, no upsert). publishListEvent item:updated fan-out." + }, + "DELETE /api/list-items/:itemId": { + "method": "DELETE", + "path": "/api/list-items/:itemId", + "auth": "oidcAuthMiddleware", + "params": [ + "itemId (path)" + ], + "file": "apps/api/src/routes/lists.ts", + "response": "{ id }", + "description": "Delete item instantly (D-06). Delete-wins semantics (D-09): no rollback path. Access-gated: owner or sharee. publishListEvent item:deleted fan-out." } } } diff --git a/.planning/intel/arch-decisions.json b/.planning/intel/arch-decisions.json index f8ce2e5..0b6a610 100644 --- a/.planning/intel/arch-decisions.json +++ b/.planning/intel/arch-decisions.json @@ -1,69 +1,160 @@ { "_meta": { - "updated_at": "2026-06-09T00:00:00Z", - "commit": "01f7456b81dd55d477d0bd7530df818f61a873c2", - "version": 1 + "updated_at": "2026-06-09T18:56:37.788Z", + "commit": "99f59c3999f4ef992f01ef8f8a38c1d86d2f2a0f", + "version": 3 }, "entries": { "broker-cache-api-pattern": { "title": "Broker-Cache-API pattern (two planes never cross)", "decision": "Backend split into a broker plane (apps/api/src/broker/) that owns all Fastmail I/O and an API plane (apps/api/src/routes/) that reads only from MariaDB. Broker crons are not reachable from the HTTP layer.", - "files": ["apps/api/src/broker/poller.ts", "apps/api/src/broker/outboxWorker.ts", "apps/api/src/routes/"] + "files": [ + "apps/api/src/broker/poller.ts", + "apps/api/src/broker/outboxWorker.ts", + "apps/api/src/routes/" + ] }, "write-broker-boundary": { "title": "Write-broker boundary invariant", "decision": "No route file imports tsdav or createFastmailClient; no broker file handles HTTP requests. Routes enqueue calendar_outbox rows and return 202 (optimistic-accept); the outbox worker performs the Fastmail write asynchronously.", - "files": ["apps/api/src/routes/events.ts", "apps/api/src/broker/write.ts"] + "files": [ + "apps/api/src/routes/events.ts", + "apps/api/src/broker/write.ts" + ] }, "identity-keying": { "title": "Identity keyed on oidc_iss + oidc_sub", "decision": "Users are keyed on oidc_iss + oidc_sub (never email). A hex color from the palette is auto-assigned on first login.", - "files": ["apps/api/src/routes/me.ts", "apps/api/src/auth/middleware.ts"] + "files": [ + "apps/api/src/routes/me.ts", + "apps/api/src/auth/middleware.ts" + ] }, "D-03-writable-set": { "title": "D-03 calendar ownership / writable-set predicate", "decision": "Every writable-set query uses WHERE userId = currentUser.id OR isShared = true. Another member's personal calendar is a read-only overlay.", - "files": ["apps/api/src/routes/events.ts"] + "files": [ + "apps/api/src/routes/events.ts" + ] }, "D-13-dual-field-dtstart": { "title": "D-13 all-day vs timed events (dual dtstart fields)", "decision": "dtstart_utc is NULL for all-day events; dtstart_date is NULL for timed events. Never coerce DATE to DATETIME.", - "files": ["apps/api/src/db/schema.ts"] + "files": [ + "apps/api/src/db/schema.ts" + ] }, "D-16-shared-fastmail-account": { "title": "D-16 shared Fastmail account, per-member credentials", "decision": "Both members share one Fastmail account. Calendar identity in DB is (userId, url) — the same collection URL appears once per member credential. CalDAV credential per member is stored AES-256-GCM encrypted in member_credentials.", - "files": ["apps/api/src/db/schema.ts", "apps/api/src/broker/poller.ts"] + "files": [ + "apps/api/src/db/schema.ts", + "apps/api/src/broker/poller.ts" + ] }, "outbox-status-machine": { "title": "Outbox status machine", "decision": "calendar_outbox rows transition pending -> done | failed | dead. failed rows retry up to a limit; dead is terminal. The sync-status endpoint surfaces worst-status-first per uid.", - "files": ["apps/api/src/broker/outboxWorker.ts", "apps/api/src/routes/events.ts"] + "files": [ + "apps/api/src/broker/outboxWorker.ts", + "apps/api/src/routes/events.ts" + ] }, "oidc-behind-pangolin": { "title": "OIDC behind Pangolin requires OIDC_AUTH_EXTERNAL_URL", "decision": "OIDC_AUTH_EXTERNAL_URL must be set to the public HTTPS URL to construct a correct redirect_uri; without it the callback resolves to the internal container address.", - "files": ["apps/api/src/auth/middleware.ts", "apps/api/src/index.ts"] + "files": [ + "apps/api/src/auth/middleware.ts", + "apps/api/src/index.ts" + ] }, "dev-auth-bypass": { "title": "Dev auth bypass", "decision": "DEV_AUTH_BYPASS=true with NODE_ENV!=production injects DEV_USER via Hono context; OIDC middleware is never mounted in this mode.", - "files": ["apps/api/src/auth/devBypass.js", "apps/api/src/index.ts"] + "files": [ + "apps/api/src/auth/devBypass.js", + "apps/api/src/index.ts" + ] }, "pwa-static-serving": { "title": "PWA static serving + SPA fallback", "decision": "Hono serveStatic serves ./public (Vite build output); SPA routes fall through to an index.html catch-all registered after /health, /api/*, and /callback so those win.", - "files": ["apps/api/src/index.ts"] + "files": [ + "apps/api/src/index.ts" + ] }, "schedule-x-routing": { "title": "Schedule-X calendar routing", "decision": "Events are routed to Schedule-X calendars by isShared ? 'shared' : String(ownerUserId) — never by calendarId. hydrateEvents.ts enforces this.", - "files": ["apps/pwa/src/hydrateEvents.ts", "apps/pwa/src/components/CalendarShell.tsx"] + "files": [ + "apps/pwa/src/lib/hydrateEvents.ts", + "apps/pwa/src/components/CalendarShell.tsx" + ] }, "state-ownership": { "title": "Client state ownership split", "decision": "Server state is owned by TanStack Query; UI-only state (selected range, color map, drawer) by Zustand. Schedule-X renders the calendar UI.", - "files": ["apps/pwa/src/store/calendarStore.ts", "apps/pwa/src/components/CalendarShell.tsx"] + "files": [ + "apps/pwa/src/store/calendarStore.ts", + "apps/pwa/src/components/CalendarShell.tsx" + ] + }, + "lists-storage-mariadb-not-caldav": { + "title": "Lists stored in MariaDB, not CalDAV (Phase 4)", + "decision": "Named lists and items are app-owned data in MariaDB (lists, list_items, list_shares tables), not pushed to Fastmail. CalDAV is exclusively for calendar events.", + "files": [ + "apps/api/src/db/schema.ts", + "apps/api/src/routes/lists.ts" + ] + }, + "D-01-D-02-list-sharing": { + "title": "D-01/D-02 list sharing via join table (member-count-agnostic)", + "decision": "isShared=true (default) triggers auto-insert of list_shares rows for all other users at create/patch time. Shares are server-managed only — no client-writable shares endpoint (T-04-08). list_shares join table is member-count-agnostic for future N-member expansion.", + "files": [ + "apps/api/src/routes/lists.ts", + "apps/api/src/db/schema.ts" + ] + }, + "D-04-scoped-sse-fan-out": { + "title": "D-04 scoped SSE fan-out — per-list channels, not global", + "decision": "GET /api/sse/lists resolves the caller's accessible list IDs via getAccessibleListIds, then subscribes one listEmitter channel per ID. Private lists of other members are never delivered. In-memory EventEmitter singleton (D-18) — no Redis; single-process, no replicas.", + "files": [ + "apps/api/src/routes/sse.ts", + "apps/api/src/lib/listEmitter.ts", + "apps/api/src/lib/listAccess.ts" + ] + }, + "D-08-per-field-lww-patch": { + "title": "D-08 per-field last-write-wins PATCH for list items", + "decision": "PATCH /api/list-items/:itemId accepts exactly one field (checked | text | position). Zod enforces single-field constraint. Prevents one client's stale read overwriting concurrent updates to other fields.", + "files": [ + "apps/api/src/routes/lists.ts" + ] + }, + "D-13-fractional-rank": { + "title": "D-13 fractional-indexing rank for list item ordering", + "decision": "list_items.rank is a varchar(255) COLLATE utf8mb4_bin using fractional-indexing strings. A single drag-reorder writes only the moved item's rank (one-row write). utf8mb4_bin collation required so uppercase-prefixed ranks (e.g. 'Zz') sort before lowercase (e.g. 'a0'), matching JS string order.", + "files": [ + "apps/api/src/db/schema.ts", + "apps/api/src/lib/rank.ts", + "apps/pwa/src/routes/ListDetail.tsx" + ] + }, + "D-10-D-11-D-12-sse-resilience": { + "title": "D-10/D-11/D-12 SSE resilience: invalidate-not-patch, bounded backoff, polling fallback", + "decision": "D-10: SSE events carry minimal { type, listId } payload; client full-refetches via TanStack Query invalidation rather than patching cache from event payload. D-11: useListSSE implements bounded backoff (250ms→8s cap, MAX_ATTEMPTS then give-up). D-12: 30s polling fallback always active in ListDetail as safety net.", + "files": [ + "apps/pwa/src/hooks/useListSSE.ts", + "apps/pwa/src/routes/ListDetail.tsx" + ] + }, + "react-router-spa-shell": { + "title": "react-router BrowserRouter SPA shell with BottomTabBar", + "decision": "App.tsx wraps routes in BrowserRouter with declarative Routes. BottomTabBar is a sibling of Routes (not inside) so it persists across navigation. SW navigateFallback covers /lists/* deep-links.", + "files": [ + "apps/pwa/src/App.tsx", + "apps/pwa/src/components/BottomTabBar.tsx" + ] } } } diff --git a/.planning/intel/dependency-graph.json b/.planning/intel/dependency-graph.json index 15c1a64..f828d88 100644 --- a/.planning/intel/dependency-graph.json +++ b/.planning/intel/dependency-graph.json @@ -1,8 +1,8 @@ { "_meta": { - "updated_at": "2026-06-09T00:00:00Z", - "commit": "01f7456b81dd55d477d0bd7530df818f61a873c2", - "version": 1 + "updated_at": "2026-06-09T18:56:37.618Z", + "commit": "99f59c3999f4ef992f01ef8f8a38c1d86d2f2a0f", + "version": 3 }, "entries": { "hono": { @@ -10,147 +10,249 @@ "type": "production", "workspace": "@familysync/api", "invocation": "require", - "used_by": ["apps/api/src/index.ts", "apps/api/src/routes/"] + "used_by": [ + "apps/api/src/index.ts", + "apps/api/src/routes/" + ] }, "@hono/node-server": { "version": "2.0.4", "type": "production", "workspace": "@familysync/api", "invocation": "require", - "used_by": ["apps/api/src/index.ts"] + "used_by": [ + "apps/api/src/index.ts" + ] }, "@hono/oidc-auth": { "version": "1.8.3", "type": "production", "workspace": "@familysync/api", "invocation": "require", - "used_by": ["apps/api/src/auth/middleware.ts"] + "used_by": [ + "apps/api/src/auth/middleware.ts" + ] }, "@hono/zod-validator": { "version": "0.8.0", "type": "production", "workspace": "@familysync/api", "invocation": "require", - "used_by": ["apps/api/src/routes/events.ts"] + "used_by": [ + "apps/api/src/routes/events.ts", + "apps/api/src/routes/lists.ts" + ] }, "drizzle-orm": { "version": "0.45.2", "type": "production", "workspace": "@familysync/api", "invocation": "require", - "used_by": ["apps/api/src/db/client.ts", "apps/api/src/db/schema.ts", "apps/api/src/routes/"] + "used_by": [ + "apps/api/src/db/client.ts", + "apps/api/src/db/schema.ts", + "apps/api/src/routes/", + "apps/api/src/lib/listAccess.ts" + ] }, "mysql2": { "version": "3.22.4", "type": "production", "workspace": "@familysync/api", "invocation": "require", - "used_by": ["apps/api/src/db/client.ts"] + "used_by": [ + "apps/api/src/db/client.ts" + ] }, "tsdav": { "version": "2.2.2", "type": "production", "workspace": "@familysync/api", "invocation": "require", - "used_by": ["apps/api/src/broker/client.ts", "apps/api/src/broker/write.ts"] + "used_by": [ + "apps/api/src/broker/client.ts", + "apps/api/src/broker/write.ts" + ] }, "ical.js": { "version": "2.2.1", "type": "production", "workspace": "both (@familysync/api + @familysync/pwa)", "invocation": "require", - "used_by": ["apps/api/src/broker/expand.ts", "apps/api/src/broker/vevent.ts", "apps/api/src/broker/sync.ts"] + "used_by": [ + "apps/api/src/broker/expand.ts", + "apps/api/src/broker/vevent.ts", + "apps/api/src/broker/sync.ts" + ] }, "zod": { "version": "^3.25.0", "type": "production", "workspace": "@familysync/api", "invocation": "require", - "used_by": ["apps/api/src/routes/events.ts"] + "used_by": [ + "apps/api/src/routes/events.ts", + "apps/api/src/routes/lists.ts" + ] + }, + "fractional-indexing": { + "version": "^3.2.0", + "type": "production", + "workspace": "both (@familysync/api + @familysync/pwa)", + "invocation": "require", + "used_by": [ + "apps/api/src/lib/rank.ts", + "apps/pwa/src/routes/ListDetail.tsx" + ] }, "node-cron": { "version": "^4.2.1", "type": "production", "workspace": "@familysync/api", "invocation": "require", - "used_by": ["apps/api/src/broker/poller.ts", "apps/api/src/broker/outboxWorker.ts"] + "used_by": [ + "apps/api/src/broker/poller.ts", + "apps/api/src/broker/outboxWorker.ts" + ] }, "drizzle-kit": { "version": "0.31.10", "type": "development", "workspace": "@familysync/api", "invocation": "npm run db:generate / npm run db:migrate", - "used_by": ["npm run db:generate", "npm run db:migrate", "npm run db:push"] + "used_by": [ + "npm run db:generate", + "npm run db:migrate", + "npm run db:push" + ] }, "temporal-polyfill": { "version": "0.3.2", "type": "production", "workspace": "both", "invocation": "require", - "used_by": ["apps/api/src/broker/expand.ts", "apps/pwa/src/lib/eventDateTime.ts"] + "used_by": [ + "apps/api/src/broker/expand.ts", + "apps/pwa/src/lib/eventDateTime.ts" + ] }, "react": { "version": "^19.0.0", "type": "production", "workspace": "@familysync/pwa", "invocation": "require", - "used_by": ["apps/pwa/src/"] + "used_by": [ + "apps/pwa/src/" + ] + }, + "react-router": { + "version": "^7.17.0", + "type": "production", + "workspace": "@familysync/pwa", + "invocation": "require", + "used_by": [ + "apps/pwa/src/App.tsx", + "apps/pwa/src/routes/", + "apps/pwa/src/components/BottomTabBar.tsx" + ] + }, + "@dnd-kit/core": { + "version": "^6.3.1", + "type": "production", + "workspace": "@familysync/pwa", + "invocation": "require", + "used_by": [ + "apps/pwa/src/routes/ListDetail.tsx" + ] + }, + "@dnd-kit/sortable": { + "version": "^10.0.0", + "type": "production", + "workspace": "@familysync/pwa", + "invocation": "require", + "used_by": [ + "apps/pwa/src/routes/ListDetail.tsx" + ] }, "vite": { "version": "8.0.16", "type": "development", "workspace": "@familysync/pwa", "invocation": "npm run dev / npm run build", - "used_by": ["npm run dev", "npm run build"] + "used_by": [ + "npm run dev", + "npm run build" + ] }, "vite-plugin-pwa": { "version": "^1.3.0", "type": "production", "workspace": "@familysync/pwa", "invocation": "implicit", - "used_by": ["apps/pwa/vite.config.ts"] + "used_by": [ + "apps/pwa/vite.config.ts" + ] }, "@tanstack/react-query": { "version": "5.101.0", "type": "production", "workspace": "@familysync/pwa", "invocation": "require", - "used_by": ["apps/pwa/src/App.tsx", "apps/pwa/src/components/"] + "used_by": [ + "apps/pwa/src/App.tsx", + "apps/pwa/src/components/", + "apps/pwa/src/routes/ListsIndex.tsx", + "apps/pwa/src/routes/ListDetail.tsx", + "apps/pwa/src/hooks/useListSSE.ts" + ] }, "zustand": { "version": "5.0.14", "type": "production", "workspace": "@familysync/pwa", "invocation": "require", - "used_by": ["apps/pwa/src/store/calendarStore.ts"] + "used_by": [ + "apps/pwa/src/store/calendarStore.ts", + "apps/pwa/src/store/listsStore.ts" + ] }, "@schedule-x/calendar": { "version": "4.6.0", "type": "production", "workspace": "@familysync/pwa", "invocation": "require", - "used_by": ["apps/pwa/src/components/CalendarShell.tsx", "apps/pwa/src/lib/calendarConfig.ts"] + "used_by": [ + "apps/pwa/src/components/CalendarShell.tsx", + "apps/pwa/src/lib/calendarConfig.ts" + ] }, "@schedule-x/react": { "version": "4.1.0", "type": "production", "workspace": "@familysync/pwa", "invocation": "require", - "used_by": ["apps/pwa/src/components/CalendarShell.tsx"] + "used_by": [ + "apps/pwa/src/components/CalendarShell.tsx" + ] }, "lucide-react": { "version": "1.17.0", "type": "production", "workspace": "@familysync/pwa", "invocation": "require", - "used_by": ["apps/pwa/src/components/"] + "used_by": [ + "apps/pwa/src/components/" + ] }, "vitest": { "version": "^4.1.8", "type": "development", "workspace": "both", "invocation": "npm test", - "used_by": ["npm test", "npm run test:watch"] + "used_by": [ + "npm test", + "npm run test:watch" + ] } } } diff --git a/.planning/intel/file-roles.json b/.planning/intel/file-roles.json index 8028519..7e9de17 100644 --- a/.planning/intel/file-roles.json +++ b/.planning/intel/file-roles.json @@ -1,12 +1,14 @@ { "_meta": { - "updated_at": "2026-06-09T00:00:00Z", - "commit": "01f7456b81dd55d477d0bd7530df818f61a873c2", - "version": 1 + "updated_at": "2026-06-09T18:56:37.326Z", + "commit": "99f59c3999f4ef992f01ef8f8a38c1d86d2f2a0f", + "version": 3 }, "entries": { "apps/api/src/index.ts": { - "exports": ["app"], + "exports": [ + "app" + ], "imports": [ "@hono/node-server", "@hono/node-server/serve-static", @@ -14,6 +16,7 @@ "./routes/health.js", "./routes/me.js", "./routes/events.js", + "./routes/lists.js", "./routes/sse.js", "./auth/middleware.js", "./auth/devBypass.js", @@ -24,7 +27,9 @@ "notes": "Hono app factory + HTTP server; mounts routes, OIDC guard, static PWA assets. Broker workers started only when isMainModule()." }, "apps/api/src/routes/events.ts": { - "exports": ["eventsRouter"], + "exports": [ + "eventsRouter" + ], "imports": [ "node:crypto", "hono", @@ -42,8 +47,47 @@ "type": "module", "notes": "GET /api/events (windowed), POST /api/events/create, PATCH /api/events/:uid/edit, DELETE /api/events/:uid, GET /api/events/sync-status, GET /api/events/writable-calendars. Writes enqueue to calendarOutbox only — never calls Fastmail directly." }, + "apps/api/src/routes/lists.ts": { + "exports": [ + "listsRouter", + "listItemsRouter" + ], + "imports": [ + "hono", + "@hono/zod-validator", + "zod", + "drizzle-orm", + "../db/client.js", + "../db/schema.js", + "../auth/middleware.js", + "../auth/user.js", + "../auth/devBypass.js", + "../lib/rank.js", + "../lib/listEmitter.js" + ], + "type": "module", + "notes": "listsRouter: GET/POST /api/lists, PATCH/DELETE /api/lists/:id, POST/GET /api/lists/:id/items. listItemsRouter: PATCH/DELETE /api/list-items/:itemId. Owner-guard on isShared mutations (T-04-07/T-04-08). Auto-populates list_shares on isShared=true creation (D-01/D-02). publishListEvent fan-out after every mutation." + }, + "apps/api/src/routes/sse.ts": { + "exports": [ + "sseRouter" + ], + "imports": [ + "hono", + "hono/streaming", + "../auth/middleware.js", + "../auth/user.js", + "../auth/devBypass.js", + "../lib/listEmitter.js", + "../lib/listAccess.js" + ], + "type": "module", + "notes": "GET /api/sse/heartbeat — 10s interval smoke-test. GET /api/sse/lists — scoped live-list fan-out (LIST-04, D-04); subscribes per-accessible-list via subscribeListEvents; 30s keepalive heartbeat." + }, "apps/api/src/routes/me.ts": { - "exports": ["meRouter"], + "exports": [ + "meRouter" + ], "imports": [ "hono", "../auth/middleware.js", @@ -53,112 +97,288 @@ "type": "module", "notes": "GET /api/me — returns { user: { id, displayName, color } }. Upserts user on first login." }, - "apps/api/src/routes/sse.ts": { - "exports": ["sseRouter"], - "imports": ["hono", "hono/streaming"], - "type": "module", - "notes": "GET /api/sse/heartbeat — server-sent events smoke-test; 10s interval heartbeat. Phase 4 SSE fan-out to be added." - }, "apps/api/src/routes/health.ts": { - "exports": ["healthRouter"], - "imports": ["hono", "../db/client.js", "drizzle-orm"], + "exports": [ + "healthRouter" + ], + "imports": [ + "hono", + "../db/client.js", + "drizzle-orm" + ], "type": "module", "notes": "GET /health — unauthenticated. Runs SELECT 1 against DB; returns { ok, db }." }, "apps/api/src/db/schema.ts": { - "exports": ["users", "memberCredentials", "calendars", "calendarEvents", "calendarOutbox"], - "imports": ["drizzle-orm/mysql-core"], + "exports": [ + "users", + "memberCredentials", + "calendars", + "calendarEvents", + "calendarOutbox", + "lists", + "listShares", + "listItems" + ], + "imports": [ + "drizzle-orm/mysql-core" + ], "type": "config", - "notes": "Drizzle schema for all 5 MariaDB tables. calendarOutbox status enum: pending|done|failed|dead. calendarEvents dual-field dtstart (dtstartUtc / dtstartDate) for timed vs all-day." + "notes": "Drizzle schema for all 8 MariaDB tables. Phase 4 adds lists, list_shares, list_items. list_items.rank uses varcharBin (COLLATE utf8mb4_bin) for fractional-indexing sort correctness. calendarOutbox status enum: pending|done|failed|dead." }, "apps/api/src/db/client.ts": { - "exports": ["db"], - "imports": ["drizzle-orm/mysql2", "mysql2/promise"], + "exports": [ + "db" + ], + "imports": [ + "drizzle-orm/mysql2", + "mysql2/promise" + ], "type": "module", "notes": "Drizzle client bound to mysql2 pool. Reads DB_HOST/DB_PORT/DB_USER/DB_PASSWORD/DB_NAME from env." }, + "apps/api/src/lib/listEmitter.ts": { + "exports": [ + "publishListEvent", + "subscribeListEvents", + "ListEvent" + ], + "imports": [ + "node:events" + ], + "type": "module", + "notes": "In-process singleton EventEmitter for list change fan-out (D-18). Per-list channels keyed as list:${listId}. publishListEvent broadcasts; subscribeListEvents returns an unsubscribe fn. Max 200 listeners (T-04-04). Redis swap seam: abstraction boundary is inside this module." + }, + "apps/api/src/lib/listAccess.ts": { + "exports": [ + "getAccessibleListIds" + ], + "imports": [ + "drizzle-orm", + "../db/client.js", + "../db/schema.js" + ], + "type": "module", + "notes": "getAccessibleListIds(userId): returns deduped list IDs the user owns OR has a list_shares row for. Gate used by SSE endpoint to scope subscriptions (D-04, T-04-02, T-04-03)." + }, + "apps/api/src/lib/rank.ts": { + "exports": [ + "rankForAppend", + "rankBetween" + ], + "imports": [ + "fractional-indexing" + ], + "type": "module", + "notes": "Pure helpers wrapping fractional-indexing generateKeyBetween. rankForAppend(lastRank) → rank after last active item. rankBetween(prev, next) → rank between two items. No DB access." + }, "apps/api/src/auth/middleware.ts": { - "exports": ["oidcAuthMiddleware", "processOAuthCallback", "getAuth"], - "imports": ["@hono/oidc-auth", "hono"], + "exports": [ + "oidcAuthMiddleware", + "processOAuthCallback", + "getAuth" + ], + "imports": [ + "@hono/oidc-auth", + "hono" + ], "type": "module", "notes": "OIDC middleware for Hono. Reads OIDC_AUTH_EXTERNAL_URL (mandatory behind Pangolin), OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_ISSUER from env." }, "apps/api/src/auth/devBypass.ts": { - "exports": ["devAuthBypass", "DEV_USER"], - "imports": ["hono"], + "exports": [ + "devAuthBypass", + "DEV_USER" + ], + "imports": [ + "hono" + ], "type": "module", "notes": "Dev-only auth bypass middleware. Active only when DEV_AUTH_BYPASS=true AND NODE_ENV!=production. Augments Hono ContextVariableMap with 'user' key." }, "apps/api/src/auth/user.ts": { - "exports": ["upsertUser", "deriveDisplayName"], - "imports": ["../db/client.js", "../db/schema.js", "drizzle-orm"], + "exports": [ + "upsertUser", + "deriveDisplayName" + ], + "imports": [ + "../db/client.js", + "../db/schema.js", + "drizzle-orm" + ], "type": "module", "notes": "User upsert keyed on oidc_iss + oidc_sub. deriveDisplayName: name → preferred_username → email → sub." }, "apps/api/src/broker/poller.ts": { - "exports": ["startBrokerPoller"], - "imports": ["node-cron", "./sync.js", "../db/client.js", "../db/schema.js"], + "exports": [ + "startBrokerPoller" + ], + "imports": [ + "node-cron", + "./sync.js", + "../db/client.js", + "../db/schema.js" + ], "type": "module", "notes": "5-minute cron that polls Fastmail CalDAV for each member credential. ctag change-detection (D-13)." }, "apps/api/src/broker/outboxWorker.ts": { - "exports": ["startOutboxWorker"], - "imports": ["node-cron", "./write.js", "../db/client.js", "../db/schema.js"], + "exports": [ + "startOutboxWorker" + ], + "imports": [ + "node-cron", + "./write.js", + "../db/client.js", + "../db/schema.js" + ], "type": "module", "notes": "15-second cron that drains pending calendarOutbox rows. Dispatches create/update/delete to Fastmail. Status machine: pending → done|failed|dead." }, "apps/api/src/broker/sync.ts": { - "exports": ["syncCalendarsForCredential"], - "imports": ["./client.js", "./expand.js", "../db/client.js", "../db/schema.js", "ical.js"], + "exports": [ + "syncCalendarsForCredential" + ], + "imports": [ + "./client.js", + "./expand.js", + "../db/client.js", + "../db/schema.js", + "ical.js" + ], "type": "module", "notes": "CalDAV PROPFIND + REPORT → upserts calendars and calendarEvents rows." }, "apps/api/src/broker/write.ts": { - "exports": ["executeOutboxRow"], - "imports": ["./client.js", "./vevent.js", "../db/client.js", "../db/schema.js"], + "exports": [ + "executeOutboxRow" + ], + "imports": [ + "./client.js", + "./vevent.js", + "../db/client.js", + "../db/schema.js" + ], "type": "module", "notes": "Executes a single outbox row: builds VEVENT, calls tsdav PUT/DELETE with If-Match etag." }, "apps/api/src/broker/client.ts": { - "exports": ["createFastmailClient"], - "imports": ["tsdav", "./crypto.js", "../db/client.js", "../db/schema.js"], + "exports": [ + "createFastmailClient" + ], + "imports": [ + "tsdav", + "./crypto.js", + "../db/client.js", + "../db/schema.js" + ], "type": "module", "notes": "Creates a tsdav DAVClient per member credential (decrypted AES-256-GCM)." }, "apps/api/src/broker/crypto.ts": { - "exports": ["encrypt", "decrypt"], - "imports": ["node:crypto"], + "exports": [ + "encrypt", + "decrypt" + ], + "imports": [ + "node:crypto" + ], "type": "module", "notes": "AES-256-GCM encrypt/decrypt for Fastmail app passwords stored in memberCredentials." }, "apps/api/src/broker/expand.ts": { - "exports": ["expandOccurrences"], - "imports": ["ical.js", "temporal-polyfill"], + "exports": [ + "expandOccurrences" + ], + "imports": [ + "ical.js", + "temporal-polyfill" + ], "type": "module", "notes": "Expands raw VCALENDAR string into CalendarOccurrence[] for a [start, end) window. Handles RRULE, EXDATE, DST via ical.js + Temporal." }, "apps/api/src/broker/vevent.ts": { - "exports": ["buildVevent", "extractRruleString"], - "imports": ["ical.js"], + "exports": [ + "buildVevent", + "extractRruleString" + ], + "imports": [ + "ical.js" + ], "type": "module", "notes": "Builds VCALENDAR/VEVENT strings from CreateEventPayload. extractRruleString preserves RRULE on calendar-move edits." }, "apps/pwa/src/main.tsx": { "exports": [], - "imports": ["react-dom/client", "./App.tsx"], + "imports": [ + "react-dom/client", + "./App.tsx" + ], "type": "entry-point", "notes": "React root mount." }, "apps/pwa/src/App.tsx": { - "exports": ["default"], + "exports": [ + "default" + ], + "imports": [ + "react-router", + "./components/CalendarShell.js", + "./routes/ListsIndex.js", + "./routes/ListDetail.js", + "./components/BottomTabBar.js" + ], + "type": "entry-point", + "notes": "BrowserRouter shell. Routes: / → /calendar redirect, /calendar → CalendarShell, /lists → ListsIndex, /lists/:listId → ListDetail. BottomTabBar rendered as persistent sibling of Routes." + }, + "apps/pwa/src/routes/ListsIndex.tsx": { + "exports": [ + "ListsIndex" + ], "imports": [ "react", "@tanstack/react-query", - "./components/CalendarShell.tsx", - "./components/InstallPrompt.tsx" + "../api/listsClient.js", + "../components/" ], - "type": "entry-point", - "notes": "Root component. Sets up QueryClient, renders CalendarShell + InstallPrompt." + "type": "module", + "notes": "Lists overview route (/lists). TanStack Query ['lists'] → fetchLists. Renders ListCard per list, ListsEmptyState when empty, CreateListSheet for new list, ListDeleteDialog for delete confirmation. Optimistic delete with rollback." + }, + "apps/pwa/src/routes/ListDetail.tsx": { + "exports": [ + "ListDetail" + ], + "imports": [ + "react", + "@tanstack/react-query", + "fractional-indexing", + "@dnd-kit/core", + "@dnd-kit/sortable", + "../api/listsClient.js", + "../hooks/useListSSE.js", + "../components/" + ], + "type": "module", + "notes": "Single list view (/lists/:listId). Splits items into active (!checked, rank ASC) and completed sections. dnd-kit drag-to-reorder with PATCH { position }. useListSSE for live sync (D-10/D-11). 30s polling fallback (D-12). Optimistic check/uncheck + add + delete." + }, + "apps/pwa/src/api/listsClient.ts": { + "exports": [ + "fetchLists", + "createList", + "patchList", + "deleteList", + "fetchListItems", + "addItem", + "patchListItem", + "deleteItem", + "List", + "ListItem", + "ListsResponse", + "ListItemsResponse" + ], + "imports": [], + "type": "module", + "notes": "Typed fetch wrappers for all lists API endpoints. credentials: 'include' for OIDC session cookie. Same opaqueredirect pattern as client.ts." }, "apps/pwa/src/api/client.ts": { "exports": [ @@ -172,10 +392,23 @@ ], "imports": [], "type": "module", - "notes": "Typed fetch wrappers for all API endpoints. Uses credentials: 'include' + redirect: 'manual' for OIDC opaqueredirect detection." + "notes": "Typed fetch wrappers for all calendar API endpoints. Uses credentials: 'include' + redirect: 'manual' for OIDC opaqueredirect detection." + }, + "apps/pwa/src/hooks/useListSSE.ts": { + "exports": [ + "useListSSE" + ], + "imports": [ + "react", + "@tanstack/react-query" + ], + "type": "module", + "notes": "Bounded-backoff EventSource hook for /api/sse/lists (D-11). Backoff: 250ms→500ms→1s→2s→4s→cap 8s; stops after MAX_ATTEMPTS. withCredentials: true (T-04-01). On open: invalidates ['list', listId] for full refetch (D-10). On event: invalidates relevant query. Polling fallback (D-12) lives in ListDetail." }, "apps/pwa/src/components/CalendarShell.tsx": { - "exports": ["CalendarShell"], + "exports": [ + "CalendarShell" + ], "imports": [ "react", "@tanstack/react-query", @@ -195,62 +428,79 @@ "type": "module", "notes": "Top-level calendar view. Orchestrates TanStack Query fetches, Schedule-X calendar, event create/edit/delete flows, sync toasts." }, - "apps/pwa/src/components/EventForm.tsx": { - "exports": ["EventForm"], - "imports": ["react", "../api/client.ts"], + "apps/pwa/src/components/BottomTabBar.tsx": { + "exports": [ + "BottomTabBar" + ], + "imports": [ + "react", + "react-router", + "../store/listsStore.js" + ], "type": "module", - "notes": "Create/edit event form. Posts to createEvent/updateEvent. Supports recurrence presets, allDay toggle, calendar picker." + "notes": "Phone-only bottom navigation tab bar. Tabs: Calendar (/calendar) and Lists (/lists). Persistent across route changes (rendered outside ). Visibility controlled by CSS at ≥768px." }, - "apps/pwa/src/components/EventDetailPopover.tsx": { - "exports": ["EventDetailPopover"], - "imports": ["react", "../api/client.ts"], + "apps/pwa/src/store/listsStore.ts": { + "exports": [ + "useListsStore" + ], + "imports": [ + "zustand" + ], "type": "module", - "notes": "Popover shown on event click. Shows title/time/location/description, edit/delete actions." - }, - "apps/pwa/src/components/SyncStateToast.tsx": { - "exports": ["SyncStateToast"], - "imports": ["react", "@tanstack/react-query", "../api/client.ts"], - "type": "module", - "notes": "Polls /api/events/sync-status to show pending → done | failed toast for async CalDAV writes." - }, - "apps/pwa/src/components/ColorLegend.tsx": { - "exports": ["ColorLegend"], - "imports": ["react", "../store/calendarStore.ts"], - "type": "module", - "notes": "Displays per-member color swatches from calendarStore." + "notes": "Zustand UI-only state for lists surface: activeTab, createListSheetOpen. No server data. Follows calendarStore.ts pattern — no persist, no immer." }, "apps/pwa/src/store/calendarStore.ts": { - "exports": ["useCalendarStore"], - "imports": ["zustand"], + "exports": [ + "useCalendarStore" + ], + "imports": [ + "zustand" + ], "type": "module", "notes": "Zustand store for UI-only state: selectedDateRange, calendarId→color map, drawer open/closed. No server state." }, "apps/pwa/src/lib/calendarConfig.ts": { - "exports": ["buildCalendarConfig"], + "exports": [ + "buildCalendarConfig" + ], "imports": [], "type": "module", "notes": "Builds Schedule-X calendar config from member color map and MeUser." }, "apps/pwa/src/lib/hydrateEvents.ts": { - "exports": ["hydrateEvents"], - "imports": ["../api/client.ts"], + "exports": [ + "hydrateEvents" + ], + "imports": [ + "../api/client.ts" + ], "type": "module", "notes": "Maps CalendarOccurrence[] → Schedule-X event objects. Routes by isShared/ownerUserId (never calendarId)." }, "apps/pwa/src/lib/eventDateTime.ts": { - "exports": ["formatEventDateTime", "toScheduleXDateTime"], - "imports": ["temporal-polyfill"], + "exports": [ + "formatEventDateTime", + "toScheduleXDateTime" + ], + "imports": [ + "temporal-polyfill" + ], "type": "module", "notes": "Date/time formatting helpers for Schedule-X event start/end fields." }, "apps/pwa/src/lib/loginRedirect.ts": { - "exports": ["maybeRedirectToLogin"], + "exports": [ + "maybeRedirectToLogin" + ], "imports": [], "type": "module", "notes": "Top-level navigation to /api/login when OIDC 302/opaqueredirect detected. CORS-bypass strategy." }, "apps/pwa/src/lib/colorUtils.ts": { - "exports": ["assignMemberColors"], + "exports": [ + "assignMemberColors" + ], "imports": [], "type": "module", "notes": "Assigns hex colors from palette to members deterministically." diff --git a/.planning/intel/stack.json b/.planning/intel/stack.json index d1249dd..b1d576a 100644 --- a/.planning/intel/stack.json +++ b/.planning/intel/stack.json @@ -1,11 +1,18 @@ { "_meta": { - "updated_at": "2026-06-09T00:00:00Z", - "commit": "01f7456b81dd55d477d0bd7530df818f61a873c2", - "version": 1 + "updated_at": "2026-06-09T18:56:37.176Z", + "commit": "99f59c3999f4ef992f01ef8f8a38c1d86d2f2a0f", + "version": 3 }, - "languages": ["TypeScript", "SQL"], - "frameworks": ["Hono 4.12.23", "React 19", "Drizzle ORM 0.45.2"], + "languages": [ + "TypeScript", + "SQL" + ], + "frameworks": [ + "Hono 4.12.23", + "React 19", + "Drizzle ORM 0.45.2" + ], "tools": [ "Vite 8.0.16", "vite-plugin-pwa 1.3.0", @@ -19,12 +26,15 @@ "package_manager": "pnpm 11.5.1", "runtime": "Node.js 22 LTS", "database": "MariaDB via mysql2 3.22.4", - "cache": "Redis (ioredis — planned for Phase 4 list sync; not yet wired)", + "cache": "Redis (ioredis — available in infra; not yet wired; in-memory EventEmitter used for Phase 4 list SSE fan-out)", "auth": "Authelia OIDC — authorization_code + PKCE via @hono/oidc-auth 1.8.3", "calendar_backend": "Fastmail CalDAV via tsdav 2.2.2 + ical.js 2.2.1", "calendar_ui": "@schedule-x/calendar 4.6.0", "server_state": "@tanstack/react-query 5.101.0", "client_state": "zustand 5.0.14", + "routing": "react-router 7.17.0 (BrowserRouter, /calendar + /lists + /lists/:listId)", + "drag_and_drop": "@dnd-kit/core 6.3.1 + @dnd-kit/sortable 10.0.0 (list item reorder)", + "fractional_rank": "fractional-indexing 3.2.0 (list item ordering — utf8mb4_bin collation in DB)", "content_formats": [ "TypeScript (source)", "SQL (Drizzle migrations)",