From 2a093468a47890892eb40b8422f28f01e08a2b3b Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Tue, 9 Jun 2026 11:33:33 -0400 Subject: [PATCH] chore: build intel index (.planning/intel/) --- .planning/intel/API-SURFACE.md | 105 +++++++++++ .planning/intel/api-map.json | 100 ++++++++++ .planning/intel/arch-decisions.json | 69 +++++++ .planning/intel/dependency-graph.json | 156 ++++++++++++++++ .planning/intel/file-roles.json | 259 ++++++++++++++++++++++++++ .planning/intel/stack.json | 43 +++++ 6 files changed, 732 insertions(+) create mode 100644 .planning/intel/API-SURFACE.md create mode 100644 .planning/intel/api-map.json create mode 100644 .planning/intel/arch-decisions.json create mode 100644 .planning/intel/dependency-graph.json create mode 100644 .planning/intel/file-roles.json create mode 100644 .planning/intel/stack.json diff --git a/.planning/intel/API-SURFACE.md b/.planning/intel/API-SURFACE.md new file mode 100644 index 0000000..288b09c --- /dev/null +++ b/.planning/intel/API-SURFACE.md @@ -0,0 +1,105 @@ +# API Surface + +> Generated from `.planning/intel/api-map.json`. Do not edit by hand. + +## `GET /health` + +- **method:** GET +- **path:** /health +- **auth:** none +- **file:** apps/api/src/routes/health.ts +- **description:** DB liveness probe. Returns { ok: true, db: 'up' } or 503. + +## `GET /callback` + +- **method:** GET +- **path:** /callback +- **auth:** none (OIDC callback — must be before auth guard) +- **file:** apps/api/src/index.ts +- **description:** OIDC authorization-code callback. Processed by @hono/oidc-auth processOAuthCallback. + +## `GET /api/login` + +- **method:** GET +- **path:** /api/login +- **auth:** oidcAuthMiddleware +- **file:** apps/api/src/index.ts +- **description:** Auth entry point. Redirects to / after successful OIDC login. PWA navigates here for re-auth. + +## `GET /api/me` + +- **method:** GET +- **path:** /api/me +- **auth:** oidcAuthMiddleware +- **file:** apps/api/src/routes/me.ts +- **response:** { user: { id: number, displayName: string|null, color: string } } +- **description:** Returns authenticated member's identity and assigned color. Upserts user row on first call. + +## `GET /api/events` + +- **method:** GET +- **path:** /api/events +- **auth:** oidcAuthMiddleware +- **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. + +## `POST /api/events/create` + +- **method:** POST +- **path:** /api/events/create +- **auth:** oidcAuthMiddleware +- **body:** CreateEventPayload (title, allDay, start, end, recurrence?, location?, description?, calendarUrl?) +- **file:** apps/api/src/routes/events.ts +- **response:** 202 { uid: string } +- **description:** Enqueues create to calendarOutbox. Async CalDAV write-back via outbox worker. Returns uid immediately. + +## `PATCH /api/events/:uid/edit` + +- **method:** PATCH +- **path:** /api/events/:uid/edit +- **auth:** oidcAuthMiddleware +- **params:** uid (path) +- **body:** CreateEventPayload +- **file:** apps/api/src/routes/events.ts +- **response:** 202 { uid: string } +- **description:** Enqueues update (or delete+create pair for calendar-move) to calendarOutbox. Async write-back. + +## `DELETE /api/events/:uid` + +- **method:** DELETE +- **path:** /api/events/:uid +- **auth:** oidcAuthMiddleware +- **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. + +## `GET /api/events/sync-status` + +- **method:** GET +- **path:** /api/events/sync-status +- **auth:** oidcAuthMiddleware +- **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. + +## `GET /api/events/writable-calendars` + +- **method:** GET +- **path:** /api/events/writable-calendars +- **auth:** oidcAuthMiddleware +- **file:** apps/api/src/routes/events.ts +- **response:** { calendars: [{ url, displayName, color, isShared }] } +- **description:** Authoritative D-03 writable set: member's own calendars + shared Family calendar. Client never derives this itself. + +## `GET /api/sse/heartbeat` + +- **method:** GET +- **path:** /api/sse/heartbeat +- **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. diff --git a/.planning/intel/api-map.json b/.planning/intel/api-map.json new file mode 100644 index 0000000..661a14f --- /dev/null +++ b/.planning/intel/api-map.json @@ -0,0 +1,100 @@ +{ + "_meta": { + "updated_at": "2026-06-09T00:00:00Z", + "commit": "01f7456b81dd55d477d0bd7530df818f61a873c2", + "version": 1 + }, + "entries": { + "GET /health": { + "method": "GET", + "path": "/health", + "auth": "none", + "file": "apps/api/src/routes/health.ts", + "description": "DB liveness probe. Returns { ok: true, db: 'up' } or 503." + }, + "GET /callback": { + "method": "GET", + "path": "/callback", + "auth": "none (OIDC callback — must be before auth guard)", + "file": "apps/api/src/index.ts", + "description": "OIDC authorization-code callback. Processed by @hono/oidc-auth processOAuthCallback." + }, + "GET /api/login": { + "method": "GET", + "path": "/api/login", + "auth": "oidcAuthMiddleware", + "file": "apps/api/src/index.ts", + "description": "Auth entry point. Redirects to / after successful OIDC login. PWA navigates here for re-auth." + }, + "GET /api/me": { + "method": "GET", + "path": "/api/me", + "auth": "oidcAuthMiddleware", + "file": "apps/api/src/routes/me.ts", + "response": "{ user: { id: number, displayName: string|null, color: string } }", + "description": "Returns authenticated member's identity and assigned color. Upserts user row on first call." + }, + "GET /api/events": { + "method": "GET", + "path": "/api/events", + "auth": "oidcAuthMiddleware", + "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." + }, + "POST /api/events/create": { + "method": "POST", + "path": "/api/events/create", + "auth": "oidcAuthMiddleware", + "body": "CreateEventPayload (title, allDay, start, end, recurrence?, location?, description?, calendarUrl?)", + "file": "apps/api/src/routes/events.ts", + "response": "202 { uid: string }", + "description": "Enqueues create to calendarOutbox. Async CalDAV write-back via outbox worker. Returns uid immediately." + }, + "PATCH /api/events/:uid/edit": { + "method": "PATCH", + "path": "/api/events/:uid/edit", + "auth": "oidcAuthMiddleware", + "params": ["uid (path)"], + "body": "CreateEventPayload", + "file": "apps/api/src/routes/events.ts", + "response": "202 { uid: string }", + "description": "Enqueues update (or delete+create pair for calendar-move) to calendarOutbox. Async write-back." + }, + "DELETE /api/events/:uid": { + "method": "DELETE", + "path": "/api/events/:uid", + "auth": "oidcAuthMiddleware", + "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." + }, + "GET /api/events/sync-status": { + "method": "GET", + "path": "/api/events/sync-status", + "auth": "oidcAuthMiddleware", + "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." + }, + "GET /api/events/writable-calendars": { + "method": "GET", + "path": "/api/events/writable-calendars", + "auth": "oidcAuthMiddleware", + "file": "apps/api/src/routes/events.ts", + "response": "{ calendars: [{ url, displayName, color, isShared }] }", + "description": "Authoritative D-03 writable set: member's own calendars + shared Family calendar. Client never derives this itself." + }, + "GET /api/sse/heartbeat": { + "method": "GET", + "path": "/api/sse/heartbeat", + "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." + } + } +} diff --git a/.planning/intel/arch-decisions.json b/.planning/intel/arch-decisions.json new file mode 100644 index 0000000..f8ce2e5 --- /dev/null +++ b/.planning/intel/arch-decisions.json @@ -0,0 +1,69 @@ +{ + "_meta": { + "updated_at": "2026-06-09T00:00:00Z", + "commit": "01f7456b81dd55d477d0bd7530df818f61a873c2", + "version": 1 + }, + "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/"] + }, + "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"] + }, + "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"] + }, + "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"] + }, + "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"] + }, + "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"] + }, + "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"] + }, + "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"] + }, + "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"] + }, + "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"] + }, + "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"] + }, + "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"] + } + } +} diff --git a/.planning/intel/dependency-graph.json b/.planning/intel/dependency-graph.json new file mode 100644 index 0000000..15c1a64 --- /dev/null +++ b/.planning/intel/dependency-graph.json @@ -0,0 +1,156 @@ +{ + "_meta": { + "updated_at": "2026-06-09T00:00:00Z", + "commit": "01f7456b81dd55d477d0bd7530df818f61a873c2", + "version": 1 + }, + "entries": { + "hono": { + "version": "4.12.23", + "type": "production", + "workspace": "@familysync/api", + "invocation": "require", + "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"] + }, + "@hono/oidc-auth": { + "version": "1.8.3", + "type": "production", + "workspace": "@familysync/api", + "invocation": "require", + "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"] + }, + "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/"] + }, + "mysql2": { + "version": "3.22.4", + "type": "production", + "workspace": "@familysync/api", + "invocation": "require", + "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"] + }, + "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"] + }, + "zod": { + "version": "^3.25.0", + "type": "production", + "workspace": "@familysync/api", + "invocation": "require", + "used_by": ["apps/api/src/routes/events.ts"] + }, + "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"] + }, + "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"] + }, + "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"] + }, + "react": { + "version": "^19.0.0", + "type": "production", + "workspace": "@familysync/pwa", + "invocation": "require", + "used_by": ["apps/pwa/src/"] + }, + "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"] + }, + "vite-plugin-pwa": { + "version": "^1.3.0", + "type": "production", + "workspace": "@familysync/pwa", + "invocation": "implicit", + "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/"] + }, + "zustand": { + "version": "5.0.14", + "type": "production", + "workspace": "@familysync/pwa", + "invocation": "require", + "used_by": ["apps/pwa/src/store/calendarStore.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"] + }, + "@schedule-x/react": { + "version": "4.1.0", + "type": "production", + "workspace": "@familysync/pwa", + "invocation": "require", + "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/"] + }, + "vitest": { + "version": "^4.1.8", + "type": "development", + "workspace": "both", + "invocation": "npm test", + "used_by": ["npm test", "npm run test:watch"] + } + } +} diff --git a/.planning/intel/file-roles.json b/.planning/intel/file-roles.json new file mode 100644 index 0000000..8028519 --- /dev/null +++ b/.planning/intel/file-roles.json @@ -0,0 +1,259 @@ +{ + "_meta": { + "updated_at": "2026-06-09T00:00:00Z", + "commit": "01f7456b81dd55d477d0bd7530df818f61a873c2", + "version": 1 + }, + "entries": { + "apps/api/src/index.ts": { + "exports": ["app"], + "imports": [ + "@hono/node-server", + "@hono/node-server/serve-static", + "hono", + "./routes/health.js", + "./routes/me.js", + "./routes/events.js", + "./routes/sse.js", + "./auth/middleware.js", + "./auth/devBypass.js", + "./broker/poller.js", + "./broker/outboxWorker.js" + ], + "type": "entry-point", + "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"], + "imports": [ + "node:crypto", + "hono", + "@hono/zod-validator", + "zod", + "drizzle-orm", + "../db/client.js", + "../db/schema.js", + "../broker/expand.js", + "../broker/vevent.js", + "../auth/middleware.js", + "../auth/user.js", + "../auth/devBypass.js" + ], + "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/me.ts": { + "exports": ["meRouter"], + "imports": [ + "hono", + "../auth/middleware.js", + "../auth/user.js", + "../auth/devBypass.js" + ], + "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"], + "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"], + "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." + }, + "apps/api/src/db/client.ts": { + "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/auth/middleware.ts": { + "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"], + "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"], + "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"], + "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"], + "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"], + "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"], + "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"], + "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"], + "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"], + "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"], + "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"], + "type": "entry-point", + "notes": "React root mount." + }, + "apps/pwa/src/App.tsx": { + "exports": ["default"], + "imports": [ + "react", + "@tanstack/react-query", + "./components/CalendarShell.tsx", + "./components/InstallPrompt.tsx" + ], + "type": "entry-point", + "notes": "Root component. Sets up QueryClient, renders CalendarShell + InstallPrompt." + }, + "apps/pwa/src/api/client.ts": { + "exports": [ + "fetchMe", + "fetchEvents", + "createEvent", + "updateEvent", + "deleteEvent", + "fetchSyncStatus", + "fetchWritableCalendars" + ], + "imports": [], + "type": "module", + "notes": "Typed fetch wrappers for all API endpoints. Uses credentials: 'include' + redirect: 'manual' for OIDC opaqueredirect detection." + }, + "apps/pwa/src/components/CalendarShell.tsx": { + "exports": ["CalendarShell"], + "imports": [ + "react", + "@tanstack/react-query", + "@schedule-x/react", + "../api/client.ts", + "../lib/calendarConfig.ts", + "../lib/hydrateEvents.ts", + "../lib/loginRedirect.ts", + "../store/calendarStore.ts", + "./EventDetailPopover.tsx", + "./EventForm.tsx", + "./SyncStateToast.tsx", + "./ColorLegend.tsx", + "./SkeletonCalendar.tsx", + "./ErrorBoundary.tsx" + ], + "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"], + "type": "module", + "notes": "Create/edit event form. Posts to createEvent/updateEvent. Supports recurrence presets, allDay toggle, calendar picker." + }, + "apps/pwa/src/components/EventDetailPopover.tsx": { + "exports": ["EventDetailPopover"], + "imports": ["react", "../api/client.ts"], + "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." + }, + "apps/pwa/src/store/calendarStore.ts": { + "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"], + "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"], + "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"], + "type": "module", + "notes": "Date/time formatting helpers for Schedule-X event start/end fields." + }, + "apps/pwa/src/lib/loginRedirect.ts": { + "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"], + "imports": [], + "type": "module", + "notes": "Assigns hex colors from palette to members deterministically." + } + } +} diff --git a/.planning/intel/stack.json b/.planning/intel/stack.json new file mode 100644 index 0000000..d1249dd --- /dev/null +++ b/.planning/intel/stack.json @@ -0,0 +1,43 @@ +{ + "_meta": { + "updated_at": "2026-06-09T00:00:00Z", + "commit": "01f7456b81dd55d477d0bd7530df818f61a873c2", + "version": 1 + }, + "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", + "Vitest", + "drizzle-kit 0.31.10", + "ESLint", + "node-cron" + ], + "build_system": "pnpm workspaces + tsc (api) + vite build (pwa)", + "test_framework": "Vitest", + "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)", + "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", + "content_formats": [ + "TypeScript (source)", + "SQL (Drizzle migrations)", + "iCalendar / VCALENDAR (CalDAV payloads)", + "Markdown (planning docs)" + ], + "infra": { + "hosting": "Unraid + Docker Compose", + "networking": "Pangolin/Newt tunnel (no open ports), split-DNS" + }, + "workspaces": { + "root": "familysync (pnpm workspace root)", + "api": "@familysync/api — apps/api", + "pwa": "@familysync/pwa — apps/pwa" + } +}