24 KiB
Project
FamilySync
FamilySync is a self-hosted, Dockerized family organization hub for a two-person household, modeled loosely on Skylight. It gives the family one unified, color-coded calendar and shared collaborative lists (groceries, gift ideas), reachable from a single low-friction React PWA — across a mixed Android/Fastmail and Apple ecosystem — built as one custom app on top of the family's existing paid Fastmail account. Email stays with members' existing mail clients and is out of scope.
Core Value: The household can see and co-edit one color-coded family calendar (shared + each member's personal) and shared lists from a single low-friction PWA — cross-ecosystem, no app store, no per-member calendar credential juggling.
Constraints
- Tech stack: MariaDB for the database — PostgreSQL is not available in the stack
- Infrastructure: Unraid host running Docker + Docker Compose
- Auth: Authelia (already deployed) + Local Auth — OIDC/OAuth2 for the custom app; all members authenticate through it
- Calendar backend: Fastmail (paid, existing) is the single source for all calendars via JMAP/CalDAV
- Frontend: React PWA only — no React Native, no App Store
- Networking: Split-DNS internal domain, private IPs internally; public exposure via Pangolin/Newt tunnel, no open ports
- UX: Must be slick and low-friction for a non-technical Apple member — hard constraint, drives design tradeoffs
- Scope: No email features; no forking existing apps
Technology Stack
Recommended Stack
Core Technologies
| Technology | Version | Purpose | Why Recommended |
|---|---|---|---|
| Node.js + TypeScript | 22 LTS | Backend runtime | First-class typing, same language as frontend, largest CalDAV/OIDC library ecosystem |
| Hono | 4.12.23 | HTTP framework | Web-Standards-native, first-class TypeScript, built-in SSE helper, WebSocket via @hono/node-server; lighter than Express and better ergonomics than Fastify for this size |
| Drizzle ORM | 0.45.2 | MariaDB query layer | Type-safe SQL, zero runtime overhead, native mysql2 driver support, schema-as-code migrations via drizzle-kit |
| mysql2 | 3.22.4 | MariaDB driver | The only maintained native MariaDB/MySQL driver; Drizzle targets it explicitly |
| React 19 | 19.x | PWA frontend | Required by project; concurrent features, stable |
| Vite | 8.0.x | Build tooling | De-facto standard for React PWAs; fast HMR, native ESM |
| vite-plugin-pwa | 1.3.0 | Service worker + manifest | Zero-config Workbox integration, handles install prompt, offline cache, background sync scaffolding |
Supporting Libraries
| Library | Version | Purpose | When to Use |
|---|---|---|---|
| tsdav | 2.2.2 | CalDAV client for Node.js | All calendar reads and writes against Fastmail CalDAV endpoint; handles PROPFIND, REPORT, PUT, DELETE |
| ical.js | 2.2.1 | iCalendar (.ics) parsing | Parse raw VCALENDAR/VEVENT payloads returned by tsdav; handles VTIMEZONE, RDATE, EXDATE |
| rrule | 2.8.1 | Recurrence rule expansion | Expand RRULE strings into concrete event occurrences for the calendar view; ical.js's built-in expansion is less ergonomic for UI consumption |
| web-push | 3.6.7 | Server-side VAPID push | Generate VAPID keys, sign and dispatch push messages to browser push services (APNs for iOS, FCM for Android) |
| @hono/oidc-auth | 1.8.3 | OIDC session middleware for Hono | Storage-less JWT session cookies; authorization-code + PKCE flow; works with any RFC-compliant OIDC provider including Authelia |
| openid-client | 6.8.4 | Low-level OIDC primitives | If @hono/oidc-auth proves insufficient (e.g., custom token introspection), use this as the lower-level escape hatch |
| zod | 3.24.x | Schema validation | Validate API request bodies and CalDAV event payloads before writing back to Fastmail |
| @hono/zod-validator | 0.8.0 | Hono middleware for Zod | Validate request body/query in route handlers with Zod schemas |
| @tanstack/react-query | 5.101.0 | Server state + caching | Manages calendar and list data fetching, background refetch, stale-while-revalidate; pairs with SSE for live list updates |
| zustand | 5.0.14 | Client state | UI-only state (selected date range, color assignments, drawer open/closed); keep server state in React Query |
| drizzle-kit | 0.31.10 | Schema migrations | Generates and runs MariaDB migrations from Drizzle schema definitions |
Development Tools
| Tool | Purpose | Notes |
|---|---|---|
| TypeScript 5.x | Strict typing across backend + frontend | strict: true; share types between packages via a packages/shared workspace |
| ESLint + Prettier | Lint + format | Standard config; no bikeshedding needed |
| Docker Compose | Local dev + production parity | Match Unraid stack exactly in dev |
| Vitest | Unit + integration tests | Vite-native, same config as frontend |
Installation
Backend
Frontend
Dev
Calendar Integration: CalDAV, Not JMAP
- Principal URL:
https://caldav.fastmail.com/dav/principals/user/broker@fastmail.com/ tsdavperformsPROPFINDon the principal to discover all calendar collections, then fetches each collection's events viaREPORT(calendar-query or calendar-multiget).- One app password covers all calendars owned by that account under the default "Mail, Contacts & Calendars" scope.
tsdavreturns raw iCalendar strings. Pass each toical.jsfor parsing into event objects, then userrulefor RRULE expansion into the date range the UI needs.- Write-back (create/edit/delete): PUT a new
.icsto the collection URL; DELETE by UID. node-ical: older fork with weaker RRULE support; ical.js is maintained by Mozilla and is the reference implementation- Direct
fetch/axiosagainst CalDAV: re-inventing XML namespace handling and PROPFIND parsing; tsdav exists specifically to avoid this - JMAP: not available for calendars on Fastmail today
Backend Framework
- First-class TypeScript with RPC-style type sharing (Hono RPC can export typed client for the React frontend — eliminates API drift)
- Built-in SSE streaming helper (
streamSSE) for live list updates - WebSocket support via
@hono/node-server - Runs on Node.js 22 LTS in Docker with
@hono/node-server - Prisma generates a binary engine that adds complexity in Docker images and has weaker MariaDB compatibility signals
- Drizzle uses
mysql2directly — the same driver you'd use raw; no runtime translation layer - Drizzle's
mysqlTableschema is fully MariaDB-compatible (MariaDB is wire-compatible with MySQL; Drizzle'smysqldialect works) - Type inference from schema → query results is the core value proposition; zero runtime overhead
React PWA Stack
- TanStack Query owns all server-side state: calendar events, lists, user profile. It handles background refetch, cache invalidation, and loading states. Use
queryClient.invalidateQueriesfrom SSE event handlers to keep list data live. - Zustand owns pure UI state: selected month, color assignments per calendar, drawer states. Do not put server data in Zustand.
| Requirement | Detail |
|---|---|
| Minimum iOS version | 16.4 — push is silently unavailable on earlier versions |
| Installation required | PWA must be added to Home Screen; push does not work from Safari browser tabs |
| User gesture | pushManager.subscribe() must be called inside a tap handler, not on page load |
| EU users on iOS 17.4+ | PWAs may open in Safari tabs instead of standalone mode due to DMA; affects push reach |
| Silent push | Not supported on iOS; all push messages must display a visible notification |
| Background sync | Not supported on iOS; no BackgroundSync or PeriodicBackgroundSync |
Authelia OIDC Integration
response_types: [code]grant_types: [authorization_code, refresh_token]require_pkce: true,pkce_challenge_method: S256token_endpoint_auth_method: client_secret_basic
Live List Sync
Alternatives Considered
| Recommended | Alternative | Why Not |
|---|---|---|
| Hono | Express | No native TypeScript ergonomics; no built-in SSE; larger ecosystem but more boilerplate |
| Hono | Fastify | Good choice but heavier plugin model; Hono's Web Standards alignment is better for this size |
| Drizzle | Prisma | Binary engine complicates Docker; weaker explicit MariaDB support; heavier |
| tsdav | Raw fetch + xml2js | CalDAV XML namespace handling is tedious; tsdav is the established TypeScript CalDAV client |
| ical.js | node-ical | node-ical is a fork that has diverged; ical.js is the Mozilla-maintained reference implementation |
| @hono/oidc-auth | express-openid-connect | express-openid-connect is Express-specific; Hono middleware is the correct fit |
| SSE | WebSockets | WebSockets are bidirectional; list sync is server→client only; SSE is simpler and proxy-friendly |
| CalDAV | JMAP | JMAP calendars not available on Fastmail as of 2026 |
What NOT to Use
| Avoid | Why | Use Instead |
|---|---|---|
| JMAP for calendars | Not implemented by Fastmail; spec not finalized | CalDAV via tsdav |
| Prisma | Binary engine, weaker MariaDB compat, larger footprint in Docker | Drizzle ORM |
| oidc-client-ts | Browser-side OIDC library; wrong layer for a backend-session app | @hono/oidc-auth |
| node-ical | Older fork of ical.js, less maintained, weaker RRULE handling | ical.js |
| Create React App | Deprecated February 2025 | Vite |
| PostgreSQL | Not in the Unraid stack; hard constraint | MariaDB |
| NestJS | Massive framework overhead for a two-user household app | Hono |
| Firebase/FCM as push broker | Third-party dependency; VAPID direct push works without it | web-push (VAPID) |
Version Compatibility
| Package | Compatible With | Notes |
|---|---|---|
| drizzle-orm@0.45.x | mysql2@3.x | Use drizzle-orm/mysql2 import path; mysql2@3.x uses Promises API by default |
| vite-plugin-pwa@1.3.x | Vite@8.x, Workbox@7.x | vite-plugin-pwa 0.16+ requires Node 16+; 1.x tracks Vite 6+ |
| @hono/oidc-auth@1.8.x | hono@4.x, oauth4webapi | Peer-depends on hono 4.x |
| ical.js@2.x | rrule@2.8.x | Use together: ical.js parses the RRULE string, pass to new RRule(RRule.parseString(...)) |
| web-push@3.6.x | Node.js 18+ | VAPID uses Web Crypto; works in Node.js 18+ natively |
Open Questions Flagged for Phase Research
Sources
- Fastmail API Documentation — Confirmed CalDAV-only for calendars; JMAP calendars not available
- Fastmail App Passwords — Scope covers CalDAV; single password covers all calendars in account
- Using Fastmail with CalDAV libraries — Principal URL pattern, app password auth
- Fastmail Calendar Sharing — Sharing is multi-user-account scoped; cross-account sharing unconfirmed
- tsdav npm — Version 2.2.2 confirmed
- ical.js npm — Version 2.2.1 confirmed; Mozilla-maintained
- rrule npm — Version 2.8.1 confirmed
- Hono — Version 4.12.23; Node.js adapter confirmed
- Drizzle ORM MySQL — MariaDB via mysql2 confirmed
- vite-plugin-pwa — Version 1.3.0; Workbox 7 integration
- web-push npm — Version 3.6.7
- Meet Declarative Web Push — WebKit — Safari 18.4+, iOS 18.4+ confirmed
- PWA iOS Limitations 2026 — iOS 16.4 minimum; home screen required; EU DMA regression
- Authelia Express.js Integration — Authorization code + PKCE flow; client_secret_basic
- @hono/oidc-auth GitHub — Storage-less JWT session cookies; Version 1.8.3
Conventions
Browser-based verification
- Use the
playwright-cliskill (.claude/skills/playwright-cli/) to validate UI and workflows instead of asking the operator to check manually. When a change touches the PWA, or a UI/UX decision needs grounding in real behavior, drive a real browser withplaywright-cliand observe — don't prompt the human to do it. This applies to executors and verifiers too: prefer an automatedplaywright-clicheck over acheckpoint:human-verifytask whenever the check runs in a desktop/Chromium browser. - Exception — genuinely device-only checks still need a human. iOS-Safari standalone-PWA behavior (Home-Screen install, standalone-mode OIDC redirect, iOS push) cannot be driven by
playwright-cli; keep those as human checkpoints (e.g. Phase 3 Gate 2 iOS items). - The
playwright-clibinary is global (/usr/local/bin/playwright-cli).@playwright/testis not a repo dependency — install it inapps/pwaonly if you need the spec-driven test-generation references.
Architecture
FamilySync is a pnpm monorepo with two apps: apps/api (Hono 4.x on Node 22 LTS, Drizzle ORM + MariaDB 11, TypeScript) and apps/pwa (React 19 + Vite 8 + vite-plugin-pwa). A single Docker Compose stack runs the API container (which also serves the PWA static build) and a MariaDB container, exposed through a Pangolin/Newt tunnel.
The backend handles two auth paths: local username/password (scrypt + HS256 JWT local-session cookie) and Authelia OIDC (authorization code + PKCE via @hono/oidc-auth). Both populate c.get('user'); the OIDC guard is skipped when a valid local session is present. A local user may link an OIDC identity later.
Calendar data lives exclusively in Fastmail CalDAV. The broker layer (apps/api/src/broker/) uses tsdav for PROPFIND/REPORT/PUT/DELETE, ical.js for VCALENDAR parsing, and rrule for server-side recurrence expansion. Writes are enqueued in a calendarOutbox table and drained asynchronously every 15 seconds; a ctag-based poller re-syncs calendars every 5 minutes.
Lists are persisted in MariaDB. Live list updates flow over SSE (text/event-stream) via an in-process Node.js EventEmitter; a 30-second polling fallback is always active. Push notifications (reminders + calendar change alerts) are dispatched via web-push (VAPID) to APNs/FCM.
The PWA uses TanStack Query for all server state (events, lists, user, sync status, auth mode) and Zustand for UI-only state (selected date, open panels, active tab).
familysync/
├── apps/
│ ├── api/src/
│ │ ├── index.ts # App entry: mounts routes, starts background workers
│ │ ├── routes/ # HTTP handlers (events, lists, me, push, sse, auth, admin, setup)
│ │ ├── auth/ # Local session + OIDC middleware + dev-bypass + OIDC-link
│ │ ├── broker/ # CalDAV client, sync, poller, outbox worker, RRULE expand, write
│ │ ├── db/ # Drizzle schema, mysql2 pool, migrations
│ │ └── lib/ # List/event emitters, push dispatcher, rank, guards, admin/setup helpers
│ └── pwa/src/
│ ├── App.tsx # BrowserRouter shell
│ ├── routes/ # Page-level components
│ ├── components/ # Shared UI components
│ ├── api/ # Typed fetch wrappers (client.ts, listsClient.ts)
│ ├── hooks/ # useListSSE, usePushSubscription
│ ├── store/ # Zustand stores (calendarStore, listsStore)
│ └── sw.ts # Custom Workbox service worker
├── docker-compose.yml # Production stack (api + mariadb)
└── docker-compose.dev.yml # Dev overrides
See docs/ARCHITECTURE.md for the full Mermaid component diagram, data-flow walkthroughs, and key abstractions table.
Project Skills
No project skills found. Add skills to any of: .claude/skills/, .agents/skills/, .cursor/skills/, .github/skills/, or .codex/skills/ with a SKILL.md index file.
GSD Workflow Enforcement
Before using Edit, Write, or other file-changing tools, start work through a GSD command so planning artifacts and execution context stay in sync.
Use these entry points:
/gsd-quickfor small fixes, doc updates, and ad-hoc tasks/gsd-debugfor investigation and bug fixing/gsd-execute-phasefor planned phase work
Do not make direct repo edits outside a GSD workflow unless the user explicitly asks to bypass it.
Developer Profile
Generated by GSD from session_analysis. Run
/gsd-profile-userto update.
| Dimension | Rating | Confidence |
|---|---|---|
| Communication | conversational | MEDIUM |
| Decisions | fast-intuitive | MEDIUM |
| Explanations | concise | MEDIUM |
| Debugging | diagnostic | MEDIUM |
| UX Philosophy | design-conscious | MEDIUM |
| Vendor Choices | opinionated | LOW |
| Frustrations | instruction-adherence | MEDIUM |
| Learning | self-directed | MEDIUM |
Directives:
- Communication: Respond in a natural, conversational register. Expect messages that bundle multiple observations and a directive together -- address each point. Match brevity for short imperative confirmations, but engage the reasoning when the developer thinks aloud.
- Decisions: Present options concisely and expect a fast decision. Use clearly enumerated choices so the developer can triage them in one pass. Do not over-deliberate or request repeated confirmation -- move forward once a disposition is given.
- Explanations: Give brief explanations focused on the key decision or the 'why this is expected', then the change. Assume the developer reads and understands the implementation. When they ask 'is this fine/correct', answer the specific concern directly rather than expanding into a full tutorial.
- Debugging: When debugging, diagnose the root cause before patching and explain what caused the behavior. The developer supplies reproduction detail and often a partial theory -- confirm or refute it directly and pull real evidence (logs, actual config) rather than guessing.
- UX Philosophy: Treat UI/UX polish as first-class work, not deferred cleanup. Get layout, copy, placement, and visual feel right during implementation. Aim for a warm, friendly, low-friction aesthetic (rounded, comfortable, 'at home'). Flag and fix UI bugs (centering, navigation, missing controls) proactively.
- Vendor Choices: Respect the already-decided stack and the developer's stated infrastructure choices -- do not propose swapping established tools. When a new library or API is in question, expect the developer to want it validated rather than taken on faith. Confirm whether this matches their general tool-selection preference.
- Frustrations: Follow standing requirements exactly, especially passing local CI gates (prettier, eslint, gitleaks/secret scan, typecheck) BEFORE pushing -- this is a recurring pain point. When the developer states a fact about their environment, accept it and do not argue from an outdated model. Apply the instructed fix directly rather than re-litigating causes already understood.
- Learning: Assume the developer experiments and investigates independently. Answer specific targeted questions precisely rather than offering unsolicited walkthroughs. When introducing something new, point to the concrete thing to check or run so they can verify it hands-on themselves.