10 KiB
Project Research Summary
Project: FamilySync — v1.1 "Operability & Polish" Domain: Self-hosted family calendar + lists PWA (operability/admin milestone on a shipped v1.0) Researched: 2026-06-10 Confidence: HIGH (all findings grounded in direct v1.0 source inspection + v1.0 retrospective)
Executive Summary
v1.1 adds six operability/polish features to the proven v1.0 stack (Node 22 + Hono + Drizzle/MariaDB + tsdav/ical.js + web-push, React 19 + Vite + Schedule-X PWA, on Unraid/Docker behind Authelia OIDC + Pangolin/Newt, self-hosted Gitea with an Actions runner). The core stack is unchanged. Every feature reuses existing capabilities; only one new dependency is warranted — @playwright/test (dev, apps/pwa scope) for the authenticated mobile test harness. No new runtime packages: the setup wizard's validations are all covered by zod + mysql2 + native fetch + Buffer/web-push.
The six features: (1) per-event reminders — VALARM authoring on the event form + a scheduler that honors each event's lead instead of the hardcoded 15-min; (2) event-driven outbox drain — cut perceived write-back latency from ~15s to ~1s; (3) admin Settings — role-gated UI to manage encrypted app passwords and designate the shared calendar; (4) initial setup wizard — first-run validated bootstrap of env/VAPID/DB/app-password; (5) Gitea CI — regression gate on PR + Docker image publish; (6) mobile-emulated authed Playwright harness.
Three preservation rules are non-negotiable and drive the design: VALARM authoring must preserve native-client alarms on edit (never rebuild-from-scratch and silently strip); the outbox durability guarantees (optimistic-202, create-before-delete ordering, drain concurrency guard, fresh-etag-before-PUT, per-uid exactly-once dedup) must be unchanged when the drain goes event-driven; and wizard-collected secrets (VAPID private key, APP_PASSWORD_ENCRYPTION_KEY) must stay in env — never touch the DB or any response body.
Key Findings
Recommended Stack
No stack change. One new dev dependency; everything else reuses v1.0.
Core additions:
@playwright/test(dev,apps/pwa): the globalplaywright-clibinary is interactive tooling and exposes nostorageState/devicespresets —@playwright/testis required for CI spec files doing authenticated, device-emulated runs. The two coexist. Auth via the existingDEV_AUTH_BYPASSavoids mocking Authelia.- Gitea Actions workflows (
.gitea/workflows/*.yml, no npm packages): GitHub-Actions-compatible syntax butruns-on: self-hosted; job imagecatthehacker/ubuntu:act-latest; MariaDB service containermariadb:11withhealthcheck.sh --connect --innodb_initialized(NOTmysqladmin ping— removed in MariaDB 11); Docker push viadocker/login-action@v3+docker/build-push-action@v5needs a Gitea PAT withwrite:packagescope (no built-in token has registry push rights). - Setup wizard validation — zero new deps: env presence via
zod.safeParse, DB viamysql2connect, VAPID viaBuffer.from(key,'base64url').length === 32, OIDC via nativefetch('/.well-known/openid-configuration').
Expected Features
Must have (table stakes):
- Per-event reminder selector with preset offsets (None / 5m / 10m / 15m / 30m / 1h / 2h / 1d / 2d); "None" is the default (no VALARM, no push). All-day events fire at 9 AM on the alert day (Apple convention). Existing VALARMs round-trip — never silently stripped.
- Admin Settings scoped to exactly two tasks: rotate/re-enter a member's app password (with inline CalDAV test) and toggle
calendars.is_shared. Singleusers.is_adminboolean gate. - Setup wizard: validated first-run steps (DB, app URL, OIDC, session secret auto-gen, encryption key auto-gen, VAPID auto-gen + structural check, admin account, Fastmail app password CalDAV PROPFIND test). Inline per-field validation; Next disabled until step passes.
- Faster write-back: target < 2s perceived; trigger an immediate drain on enqueue, keep the interval as fallback.
Should have (competitive / differentiator):
- Gitea CI PR gate + on-merge Docker publish.
- Mobile Playwright harness (
devices['iPhone 15'], stored auth viaDEV_AUTH_BYPASS). - Multiple alarms per event (2× VALARM) — stretch, defer to v1.2.
Anti-features (explicitly OUT — scope creep for a 2-member household): notification-preferences UI, reminder snooze, wizard re-run, audit log, health dashboard, user management, provider abstraction (stays backlog 999.1), self-service member onboarding mixed into the wizard (stays backlog 999.5), real-device iOS CI.
Architecture Approach
Findings grounded in the actual v1.0 codebase. Integration points (real paths):
Major components:
- DB migration —
users.is_admin BOOLEAN DEFAULT 0,calendar_events.reminder_lead_minutes INT NULL, anapp_config/setup-state table. Foundation; blocks the role-gated and reminder work. (generate+migrate, neverpush.) - Event-driven drain — in-process
EventEmitter(lib/outboxTrigger.ts, mirroring existinglistEmitter.ts); signal afterdb.insert(calendarOutbox)in the three write handlers inroutes/events.ts; subscribe instartOutboxWorker(). The existingisDrainingguard already covers concurrent invocations. Redis pub/sub is wrong here — the drain is single-process by design. - VALARM write path —
buildVeventStringinbroker/vevent.tsgains areminderMinutes?param usingICAL.Component('valarm')+ICAL.Duration.fromSeconds(same ical.js surface as RRULE).eventFieldsSchema(routes/events.ts) andoutboxPayloadSchema(outboxWorker.ts) must change in sync (flagged by the IN-03 comment). - Variable-lead scheduler —
reminder_lead_minutespopulated bysync.tsparsing the VALARM TRIGGER; scheduler query usesDATE_SUB(dtstart_utc, INTERVAL reminder_lead_minutes MINUTE)over a ±1-min window; dedup key becomes compounduid:dtstartMs. Drop theisShared-only restriction for reminder pushes (a user who set an alarm wants it regardless of calendar). - Admin role + setup wizard —
routes/admin.tswithrequireAdminmiddleware reusingbroker/crypto.ts; setup wizard and admin Settings are two frontend consumers of the same/api/admin/*+/api/setup/*routes (do not duplicate).GET /api/setup/statusmounts before the OIDC guard (like/health). VAPID/AES keys stay in env — wizard generates + displays for the operator to copy.
Recommended Phase Structure (dependency-ordered)
Starting at Phase 7 (continues v1.0 numbering). Critical path with two fully independent parallel tracks:
- DB foundation (migration: is_admin, reminder_lead_minutes, setup-state) — blocks the role/reminder work.
- Faster write-back (event-driven drain) — small, low-risk, immediate benefit; independent after migration.
- Admin role + routes — precondition for both admin Settings and setup wizard.
- Setup wizard (backend + UI) — depends on the admin/role + config plumbing.
- Admin Settings UI — depends on admin role; shares routes with the wizard.
- Per-event reminders (VALARM authoring + variable-lead scheduler) — independent track, largest scope; keep authoring + preserve-on-edit + scheduler in one phase.
- Gitea CI — fully independent; start with a runner-probe step.
- Mobile Playwright harness — fully independent.
Tracks 7 and 8 have no code dependencies and can run parallel to anything.
Critical Pitfalls (phase-mapped)
- VALARM round-trip strips native alarms (reminders phase) — update path must extract + preserve existing VALARM from
rawVevent, not rebuild from scratch. - TRIGGER serialized as VALUE=TEXT (reminders phase) — use
ICAL.Duration.fromSeconds(-n*60), verify ICS has noVALUE=TEXT. - All-day reminder semantics undefined (reminders phase) — disable selector when
allDayin UI; guard API. - Per-event lead breaks uid-only dedup (reminders phase) — rescheduled events go invisible; fix with
uid:dtstartMscompound key. - Event-driven drain double-execution (write-back phase) — use a
drainRequestedflag checked by the interval callback, not direct concurrentrunOutboxDrain()calls; preserves create-before-delete + etag handling. - Setup endpoint reachable post-setup (wizard phase) — guard checked on every invocation (member_credentials AND VAPID env present → 423), not just at startup; never log/echo the app password; secrets never persisted to DB.
- Gitea ghost failures (CI phase) — MariaDB readiness race (healthy ≠ accepting connections) and runner env assumptions (Node 22 + pnpm not guaranteed) — start with a probe-only workflow.
Research Flags (deeper investigation during planning)
- Reminders phase: confirm Fastmail CalDAV accepts the chosen TRIGGER value type; exact
rawVeventround-trip on the update path. - Wizard phase: OIDC discovery failure handling (retry/timeout/fallback).
- CI phase: probe the actual Unraid Gitea runner (Docker socket mount, Node/pnpm versions) before designing pipelines.
- Mobile harness: fixed user (DEV_AUTH_BYPASS user 1) vs parameterized; prod service worker must be neutralized in the base context.
Well-documented — skip research: event-driven drain (EventEmitter proven in listEmitter.ts), admin/wizard routes (standard Hono + zod + React forms), Playwright device/storageState APIs.
Open Questions to Resolve in Requirements
- Admin tab visibility — both members are operators; should the non-technical member see it?
- Drain transport — confirm single-container deployment (direct in-process signal) vs any multi-replica need (would require Redis).
- Playwright auth — DEV_AUTH_BYPASS-only vs programmatic OIDC; fixed vs parameterized user.
Confidence
| Domain | Confidence | Notes |
|---|---|---|
| Stack | HIGH | v1.0 proven; one dev dep; Gitea syntax compatible (MariaDB 11 healthcheck caveat noted) |
| Features | HIGH | All have prior art; scoped to a tiny household |
| Architecture | HIGH | Grounded in real v1.0 source; component boundaries + build order sound |
| Pitfalls | HIGH | 15 pitfalls from codebase review + RFC 5545 + v1.0 retrospective, each mapped to a phase |
| Unraid CI runner | MEDIUM | Runner Docker-socket/Node/pnpm state unknown until probed |