docs: complete project research (stack, features, architecture, pitfalls, summary)
This commit is contained in:
@@ -0,0 +1,424 @@
|
||||
# Architecture Research
|
||||
|
||||
**Domain:** Self-hosted family calendar + list hub (Fastmail broker + MariaDB + React PWA)
|
||||
**Researched:** 2026-06-03
|
||||
**Confidence:** MEDIUM (Fastmail personal-calendar ACL mechanics unconfirmed — see flags below)
|
||||
|
||||
## Standard Architecture
|
||||
|
||||
### System Overview
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph Public["Public Internet"]
|
||||
iOS["iOS PWA (Safari)"]
|
||||
Android["Android PWA (Chrome)"]
|
||||
end
|
||||
|
||||
subgraph Tunnel["Pangolin/Newt Tunnel (no open ports)"]
|
||||
direction TB
|
||||
Authelia["Authelia OIDC\n(already deployed)"]
|
||||
end
|
||||
|
||||
subgraph Docker["Docker Compose — Unraid"]
|
||||
direction TB
|
||||
PWA["React PWA\n(static, served by API or nginx)"]
|
||||
API["App API\n(Node/Express or Fastify)"]
|
||||
|
||||
subgraph Broker["Fastmail Broker Layer"]
|
||||
CalDAVClient["CalDAV Client\n(node-caldav / tsdav)"]
|
||||
Cache["Calendar Cache\n(MariaDB: events + ctag)"]
|
||||
Poller["Background Poller\n(cron, 5-min interval)"]
|
||||
end
|
||||
|
||||
subgraph Lists["List Domain"]
|
||||
ListAPI["List CRUD\n(REST endpoints)"]
|
||||
ListDB["lists / items tables\n(MariaDB)"]
|
||||
end
|
||||
|
||||
subgraph Realtime["Real-time Layer"]
|
||||
WSHub["WebSocket Hub\n(ws or Socket.IO)"]
|
||||
RedisPubSub["Redis Pub/Sub\n(optional, single instance OK)"]
|
||||
end
|
||||
|
||||
subgraph Push["Web Push"]
|
||||
PushSender["VAPID Push Sender\n(web-push npm)"]
|
||||
SubStore["push_subscriptions table\n(MariaDB)"]
|
||||
Scheduler["Reminder Scheduler\n(node-cron)"]
|
||||
end
|
||||
|
||||
MariaDB[("MariaDB\n(users, calendars_cache,\nlists, push_subscriptions)")]
|
||||
Redis[("Redis\n(pub/sub channels)")]
|
||||
end
|
||||
|
||||
subgraph Fastmail["Fastmail (external, source of truth)"]
|
||||
FM_Shared["Shared Family Calendar"]
|
||||
FM_Personal["Personal Calendar Collections\n(one per user)"]
|
||||
end
|
||||
|
||||
iOS -->|HTTPS via tunnel| Authelia
|
||||
Android -->|HTTPS via tunnel| Authelia
|
||||
Authelia -->|forwards authed request| API
|
||||
PWA <-->|REST + WebSocket| API
|
||||
|
||||
API --> CalDAVClient
|
||||
API --> ListAPI
|
||||
API --> WSHub
|
||||
|
||||
CalDAVClient <-->|CalDAV over HTTPS\napp password auth| FM_Shared
|
||||
CalDAVClient <-->|CalDAV over HTTPS\nper-user app password OR shared ACL| FM_Personal
|
||||
CalDAVClient --> Cache
|
||||
|
||||
Poller -->|every 5 min: PROPFIND ctag| CalDAVClient
|
||||
Poller -->|on ctag change: full sync| Cache
|
||||
|
||||
ListAPI --> ListDB
|
||||
ListDB --> MariaDB
|
||||
Cache --> MariaDB
|
||||
|
||||
WSHub <-->|subscribe/publish| RedisPubSub
|
||||
RedisPubSub --> Redis
|
||||
|
||||
ListAPI -->|on write| RedisPubSub
|
||||
PushSender --> SubStore
|
||||
SubStore --> MariaDB
|
||||
Scheduler -->|check upcoming events| MariaDB
|
||||
Scheduler --> PushSender
|
||||
ListAPI -->|on important change| PushSender
|
||||
```
|
||||
|
||||
### Component Responsibilities
|
||||
|
||||
| Component | Responsibility | Notes |
|
||||
|-----------|----------------|-------|
|
||||
| React PWA | All UI rendering; calendar view, list co-edit, push subscription registration | Static build; served from API container or separate nginx |
|
||||
| App API | Auth middleware (OIDC token validation), REST endpoints, WebSocket upgrade, orchestrates broker + list + push | Single Node process; Fastify recommended for performance |
|
||||
| CalDAV Client | Issues PROPFIND/REPORT against caldav.fastmail.com using app password(s); parses iCalendar | Use `tsdav` (TypeScript, actively maintained) or `node-ical` for parsing |
|
||||
| Calendar Cache | Stores raw VEVENT blobs + ctag per calendar in MariaDB; serves as the read path for the API | Never exposed directly; always through API |
|
||||
| Background Poller | Cron job running inside API process; checks ctag every 5 min, triggers full REPORT sync on change | 5-min polling is sufficient for family use; no Fastmail push webhook available |
|
||||
| List CRUD | REST handlers for list/item create-read-update-delete; emits to Redis on every write | Simple; MariaDB is source of truth |
|
||||
| WebSocket Hub | Maintains open connections per authenticated user; pushes Redis messages to correct connections | Keyed by user ID extracted from OIDC sub claim |
|
||||
| Redis Pub/Sub | Message bus for list change events; decouples list writes from WebSocket delivery | Single-instance Redis is fine for 2-person household; no clustering needed |
|
||||
| VAPID Push Sender | Calls browser push services (FCM, APNs Web Push) with encrypted payloads | `web-push` npm package; keys stored in environment, not DB |
|
||||
| Reminder Scheduler | Cron job: queries events starting in next 15 min (configurable), fires push notifications | Runs inside API process; reads from calendar cache |
|
||||
| MariaDB | Persistent storage: users, calendar event cache, lists, push subscriptions | Single source for everything the app owns |
|
||||
|
||||
## Recommended Project Structure
|
||||
|
||||
```
|
||||
familysync/
|
||||
├── apps/
|
||||
│ ├── api/ # Node backend
|
||||
│ │ ├── src/
|
||||
│ │ │ ├── auth/ # OIDC token validation, user upsert
|
||||
│ │ │ ├── broker/ # Fastmail CalDAV client + cache sync
|
||||
│ │ │ │ ├── client.ts # tsdav wrapper
|
||||
│ │ │ │ ├── poller.ts # ctag polling cron
|
||||
│ │ │ │ ├── sync.ts # REPORT sync → DB write
|
||||
│ │ │ │ └── expand.ts # RRULE expansion + timezone normalise
|
||||
│ │ │ ├── calendars/ # REST routes: GET /calendars, GET /events
|
||||
│ │ │ ├── lists/ # REST routes + Redis emit
|
||||
│ │ │ ├── push/ # VAPID sender, subscription routes, scheduler
|
||||
│ │ │ ├── realtime/ # WebSocket server, Redis subscriber
|
||||
│ │ │ ├── db/ # Knex migrations + query helpers (MariaDB)
|
||||
│ │ │ └── server.ts # Fastify app bootstrap
|
||||
│ │ └── Dockerfile
|
||||
│ └── pwa/ # React PWA
|
||||
│ ├── src/
|
||||
│ │ ├── features/
|
||||
│ │ │ ├── calendar/ # Calendar view, event form
|
||||
│ │ │ └── lists/ # List view, item row, optimistic updates
|
||||
│ │ ├── auth/ # OIDC redirect handling, token storage
|
||||
│ │ ├── push/ # Service worker registration, push consent
|
||||
│ │ └── api/ # Typed fetch client
|
||||
│ ├── public/
|
||||
│ │ └── sw.js # Service worker (push + offline cache)
|
||||
│ └── Dockerfile
|
||||
├── db/
|
||||
│ └── migrations/ # Knex migration files (versioned)
|
||||
├── docker-compose.yml
|
||||
└── .env.example
|
||||
```
|
||||
|
||||
### Structure Rationale
|
||||
|
||||
- **apps/api/src/broker/:** Isolation boundary. All Fastmail I/O lives here. Nothing else imports from `broker/` except the `calendars/` routes and `push/scheduler`. Replacing CalDAV with JMAP later touches only this module.
|
||||
- **apps/api/src/db/:** Knex chosen over an ORM because MariaDB lacks full Prisma support for some JSON features; Knex gives SQL control without raw strings everywhere.
|
||||
- **apps/pwa/src/features/:** Feature-sliced structure keeps calendar and list concerns separated. Both features share the same WebSocket connection managed at app root.
|
||||
|
||||
## Architectural Patterns
|
||||
|
||||
### Pattern 1: Broker Cache with ctag-based Invalidation
|
||||
|
||||
**What:** The API never fetches from Fastmail on a user request. All calendar reads hit the MariaDB cache. A background poller issues a lightweight CalDAV PROPFIND to check the `getctag` property on each calendar collection every 5 minutes. On change, it issues a `REPORT calendar-query` (or `sync-collection` if the server supports it) to fetch only changed VEVENTs, then upserts into the cache.
|
||||
|
||||
**When to use:** Always — Fastmail has no push webhook for calendar changes. Polling is the only option.
|
||||
|
||||
**Trade-offs:** 5-min staleness is acceptable for a family calendar. Reduces Fastmail API calls to near-zero during quiet periods. Cache warms on first poll after startup.
|
||||
|
||||
```typescript
|
||||
// broker/poller.ts (sketch)
|
||||
async function poll(calendarUrl: string, knownCtag: string | null) {
|
||||
const ctag = await fetchCtag(calendarUrl); // PROPFIND
|
||||
if (ctag === knownCtag) return; // no change
|
||||
await syncCalendar(calendarUrl); // REPORT → upsert cache
|
||||
await db('calendars').where({ url: calendarUrl }).update({ ctag, synced_at: new Date() });
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 2: Single-Broker-Token with Per-Calendar App Passwords
|
||||
|
||||
**What:** The broker holds credentials for the primary Fastmail account (the project owner). The shared family calendar is readable/writable with that account's app password. For personal calendars of other household members (the wife's personal calendar), Fastmail requires that the owner either:
|
||||
|
||||
1. **Share the calendar** with the broker account — the broker then sees it appear at a different URL under its own principal (confirmed: Fastmail supports CalDAV sharing with read or read-write ACL to another Fastmail user).
|
||||
2. **Provide a separate app password** per user — the broker authenticates as each user independently to read their personal calendar.
|
||||
|
||||
Option 1 (sharing to the broker account) is preferred: one credential, no password management per user. The wife logs into Fastmail once, shares her personal calendar with the primary account, done. The broker discovers it via CalDAV principal discovery.
|
||||
|
||||
**Confidence: LOW on exact URL behavior.** Fastmail's sharing documentation confirms the sharing feature exists and follows CalDAV draft ACL standards, but does not explicitly document whether the shared calendar appears in the principal's list discovery. Treat this as a phase research item — test during the broker phase with a real Fastmail multi-user or sharing setup.
|
||||
|
||||
**Trade-offs:** If sharing is not discoverable by principal, fall back to individual app passwords stored encrypted in `.env`. Two-person household means at most 2 credentials.
|
||||
|
||||
### Pattern 3: Redis Pub/Sub for List Co-Edit
|
||||
|
||||
**What:** On every list/item write that succeeds in MariaDB, the API handler publishes a message to a Redis channel keyed by list ID. The WebSocket hub is subscribed to all relevant channels and pushes the serialized diff to connected clients.
|
||||
|
||||
**When to use:** Whenever two users might be editing the same list concurrently. For 2 people, this is "always."
|
||||
|
||||
**Trade-offs:** Redis is optional in the stack — if Redis is unavailable, fall back to polling (client polls `/lists/{id}` every 10s). Design the list API to support both modes via a feature flag.
|
||||
|
||||
```
|
||||
Client A writes item → POST /lists/1/items
|
||||
→ DB insert
|
||||
→ Redis PUBLISH list:1 { type: "item_added", item: {...} }
|
||||
→ WebSocket hub receives → pushes to all sockets subscribed to list:1
|
||||
Client B receives message → appends item to local state (no refetch)
|
||||
```
|
||||
|
||||
### Pattern 4: RRULE Expansion Client-Side from Stored Raw VEVENT
|
||||
|
||||
**What:** Store the raw VEVENT blob (including RRULE) in the cache. On read, the API returns the raw iCal data or a partially processed event object that includes the RRULE string. The PWA expands recurrences client-side using the `rrule.js` library for the displayed date window (current month ± 1 month).
|
||||
|
||||
**When to use:** For this scale. Server-side pre-expansion into a flat events table is over-engineering for a 2-person calendar.
|
||||
|
||||
**Trade-offs:** Timezone correctness requires the PWA to use the TZID from the VEVENT and the `Intl` API to resolve DST. The `rrule.js` library handles TZID correctly when fed the VTIMEZONE component. Store all DTSTART values in UTC in the DB alongside the raw blob for efficient range queries.
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Calendar Read (PWA → API → Cache)
|
||||
|
||||
```
|
||||
User opens calendar view (month X)
|
||||
→ GET /api/events?start=...&end=...
|
||||
→ API queries MariaDB: SELECT * FROM events WHERE dtstart BETWEEN ? AND ?
|
||||
→ Returns event objects (RRULE included)
|
||||
→ PWA rrule.js expands recurring events for display window
|
||||
→ Render color-coded events (color from user_calendars.color config)
|
||||
```
|
||||
|
||||
### Calendar Write (PWA → API → Fastmail)
|
||||
|
||||
```
|
||||
User creates/edits event in PWA
|
||||
→ POST/PUT /api/events
|
||||
→ API validates auth, resolves target calendar from event's calendarId
|
||||
→ broker/client.ts issues CalDAV PUT (new VEVENT) to caldav.fastmail.com
|
||||
→ On 201/204 success: upsert into local cache immediately (no wait for next poll)
|
||||
→ Return 201 to PWA
|
||||
→ PWA updates local state optimistically (already done before response)
|
||||
```
|
||||
|
||||
### Fastmail Sync Loop (background)
|
||||
|
||||
```
|
||||
Poller tick (every 5 min)
|
||||
→ PROPFIND each tracked calendar URL → fetch getctag
|
||||
→ Compare ctag to DB stored value
|
||||
→ If changed: REPORT calendar-query for the collection
|
||||
→ Parse iCalendar response
|
||||
→ For each VEVENT: upsert events table (uid as key), update dtstart UTC
|
||||
→ Update calendars.ctag
|
||||
→ If reminder threshold: Scheduler fires Web Push
|
||||
```
|
||||
|
||||
### List Co-Edit (real-time)
|
||||
|
||||
```
|
||||
User A edits list item text
|
||||
→ PATCH /api/lists/1/items/42 { text: "oat milk" }
|
||||
→ DB UPDATE → success
|
||||
→ Redis PUBLISH list:1 { op: "update", itemId: 42, text: "oat milk", userId: "A" }
|
||||
→ WS Hub (subscribed to list:1) receives message
|
||||
→ Push to all WebSocket connections for users in list:1 EXCEPT sender
|
||||
→ User B's PWA receives → update item in list state
|
||||
```
|
||||
|
||||
### Web Push Delivery
|
||||
|
||||
```
|
||||
Reminder Scheduler (cron, runs every minute)
|
||||
→ SELECT events WHERE dtstart BETWEEN now+14min AND now+15min
|
||||
AND reminder_sent = false
|
||||
→ For each event: find household users who have visibility to that calendar
|
||||
→ For each user: SELECT push_subscriptions WHERE user_id = ?
|
||||
→ For each subscription: web-push sendNotification(subscription, payload)
|
||||
→ Mark reminder_sent = true
|
||||
```
|
||||
|
||||
### Identity Mapping (OIDC → App User)
|
||||
|
||||
```
|
||||
User authenticates via Authelia OIDC
|
||||
→ PWA redirects to /auth/login → Authelia
|
||||
→ Authelia returns id_token with claims: sub, email, preferred_username, groups
|
||||
→ API receives id_token in Authorization header (or httpOnly cookie)
|
||||
→ Validate token signature against Authelia JWKS endpoint
|
||||
→ Look up users table WHERE oidc_sub = claims.sub
|
||||
→ If not found: INSERT (first login provisioning) using email as display hint
|
||||
→ Attach app user_id to request context
|
||||
→ user_id is used for: calendar color config, push subscriptions, WS channel auth
|
||||
```
|
||||
|
||||
**Key:** Use `oidc_sub` (the `sub` claim) as the stable identity anchor. Never use `email` as a unique key — it can change per Authelia docs. Store `oidc_iss + oidc_sub` as the composite stable ID.
|
||||
|
||||
## Component Build Order (Dependencies)
|
||||
|
||||
Build in this sequence — each layer depends on the previous:
|
||||
|
||||
```
|
||||
1. Infrastructure scaffold
|
||||
docker-compose.yml (API + MariaDB + Redis), DB migrations, .env wiring
|
||||
|
||||
2. Auth layer
|
||||
OIDC middleware (validate Authelia tokens, user upsert), session/cookie setup
|
||||
[nothing works without identity]
|
||||
|
||||
3. Fastmail broker — read path only
|
||||
CalDAV client, calendar discovery, cache sync, poller, event query API
|
||||
[needed before any calendar UI]
|
||||
|
||||
4. Calendar PWA — read only
|
||||
React calendar view consuming /api/events, color coding per calendar
|
||||
[validates broker correctness before write complexity]
|
||||
|
||||
5. Fastmail broker — write path
|
||||
CalDAV PUT for create/edit/delete, optimistic cache upsert
|
||||
[built on confirmed-working read path]
|
||||
|
||||
6. Lists domain
|
||||
MariaDB list/item schema, REST CRUD, basic PWA list UI
|
||||
[independent of calendar; can parallel-track after step 3]
|
||||
|
||||
7. Real-time list sync
|
||||
Redis pub/sub, WebSocket hub, PWA WebSocket client
|
||||
[enhancement on top of working list CRUD]
|
||||
|
||||
8. Web Push
|
||||
VAPID key generation, push_subscriptions table, service worker, reminder scheduler
|
||||
[built last; depends on events being cached and users having identity]
|
||||
```
|
||||
|
||||
**Parallel tracks available:** Lists (step 6) can start in parallel with calendar write (step 5) once auth and broker read are stable.
|
||||
|
||||
## Integration Points
|
||||
|
||||
### External Services
|
||||
|
||||
| Service | Integration Pattern | Auth | Gotchas |
|
||||
|---------|---------------------|------|---------|
|
||||
| Fastmail CalDAV | HTTP PROPFIND / REPORT / PUT via `tsdav` | App password (Basic Auth over HTTPS) | JMAP for calendars NOT available — CalDAV only. Use app password, not API token. Rate limits undocumented; 5-min polling is conservative. |
|
||||
| Authelia OIDC | Authorization Code flow from PWA; JWT validation in API | OIDC client_id/secret in API env | Validate against Authelia JWKS URL, not a static key. Use `sub` not `email` as stable identity. |
|
||||
| Browser Push Services (FCM/APNs Web Push) | VAPID via `web-push` npm | VAPID public/private key pair | iOS requires PWA installed to home screen (outside EU). Push endpoint URL comes from browser `PushManager.subscribe()`. |
|
||||
| Pangolin/Newt | Transparent tunnel — no changes to app | None (handled at infra layer) | API must trust `X-Forwarded-For` headers from Pangolin; set `trustProxy: true` in Fastify. |
|
||||
|
||||
### Internal Boundaries
|
||||
|
||||
| Boundary | Communication | Notes |
|
||||
|----------|---------------|-------|
|
||||
| PWA ↔ API | REST (JSON) + WebSocket | REST for CRUD + auth; WS for real-time list push. Single domain, so no CORS needed in prod. |
|
||||
| API ↔ Broker | Direct function call (same process) | Broker is a module, not a separate service. Keep it that way — no premature microservice split. |
|
||||
| API ↔ MariaDB | Knex query builder | All queries go through `db/` module. No raw SQL in route handlers. |
|
||||
| API ↔ Redis | ioredis, publish on write | WS hub uses a separate ioredis subscriber connection (required by Redis subscribe mode). |
|
||||
| Scheduler ↔ API | In-process cron (node-cron) | Poller and reminder scheduler run in same Node process. If process dies, they restart with it. |
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
### Anti-Pattern 1: Fetching from Fastmail on Every Calendar Request
|
||||
|
||||
**What people do:** Proxy each PWA calendar load directly to CalDAV — no cache.
|
||||
|
||||
**Why it's wrong:** Fastmail CalDAV has undocumented rate limits. Cold requests are slow (multi-round-trip CalDAV discovery). Calendar loads become unavailable if Fastmail is unreachable.
|
||||
|
||||
**Do this instead:** Always read from the MariaDB cache. Write-back is synchronous (PUT + cache upsert). Background poller handles incoming changes from the Fastmail app.
|
||||
|
||||
### Anti-Pattern 2: Using Email as the App User Key
|
||||
|
||||
**What people do:** Map Authelia `email` claim → `users.email` primary key, assume it's stable.
|
||||
|
||||
**Why it's wrong:** OpenID Connect spec does not guarantee `email` stability or uniqueness. Authelia docs explicitly warn against this.
|
||||
|
||||
**Do this instead:** Use `oidc_iss` + `oidc_sub` as the stable composite identity. Store email as a display/provisioning hint only.
|
||||
|
||||
### Anti-Pattern 3: Storing Recurring Events Only as Expanded Instances
|
||||
|
||||
**What people do:** Pre-expand all RRULE events into individual rows in the DB to simplify queries.
|
||||
|
||||
**Why it's wrong:** Event edits (changing the series, adding exceptions) then require reconstructing the original RRULE plus EXDATE/RDATE logic — a nightmare. Fastmail is source of truth for RRULE; the cache should reflect it faithfully.
|
||||
|
||||
**Do this instead:** Store the raw VEVENT blob. Add a `dtstart_utc` column for range queries. Expand in the PWA for display.
|
||||
|
||||
### Anti-Pattern 4: Separate Microservices for Broker, Lists, Push
|
||||
|
||||
**What people do:** Split the broker, list API, and push sender into separate containers because "separation of concerns."
|
||||
|
||||
**Why it's wrong:** For a 2-person household app on a single Unraid host, this is pure overhead. Three containers to coordinate, three deployment targets, inter-service HTTP overhead with zero benefit.
|
||||
|
||||
**Do this instead:** One API container. Three modules within it. Split to services only if one component needs independent scaling (it won't).
|
||||
|
||||
### Anti-Pattern 5: Per-User CalDAV Credentials in the PWA
|
||||
|
||||
**What people do:** Give each household member their own Fastmail app password and have the PWA authenticate to CalDAV directly.
|
||||
|
||||
**Why it's wrong:** Exposes Fastmail credentials client-side; bypasses the broker cache; defeats the aggregation purpose.
|
||||
|
||||
**Do this instead:** All Fastmail access goes through the backend broker. The PWA authenticates only to the app API.
|
||||
|
||||
## Scaling Considerations
|
||||
|
||||
This is a 2-person household app. Scaling is not a concern. The architecture should remain simple.
|
||||
|
||||
| Concern | At 2 users (target) |
|
||||
|---------|---------------------|
|
||||
| CalDAV polling | Single poller, 5-min interval, negligible load |
|
||||
| MariaDB | Single instance, no replication needed |
|
||||
| Redis | Single instance, no clustering |
|
||||
| WebSocket | Single Node process handles 2 connections trivially |
|
||||
| Web Push | At most ~10 push subscriptions (phone + desktop per user) |
|
||||
|
||||
## Open Research Flags for Phase Research
|
||||
|
||||
These items require hands-on investigation during the broker implementation phase — they cannot be resolved by documentation alone:
|
||||
|
||||
1. **Fastmail personal-calendar sharing visibility (MEDIUM risk):** Can the broker account's CalDAV principal discovery see calendars that another Fastmail account has shared to it via the in-app sharing UI? If yes, one app password suffices. If no, the fallback is storing an encrypted app password per household member. Test this before building the broker's calendar-discovery logic.
|
||||
|
||||
2. **Fastmail CalDAV rate limits (LOW risk):** No documented rate limit for CalDAV. 5-min polling with ctag pre-check (one PROPFIND per calendar per tick) is conservative and unlikely to hit limits. Monitor HTTP 429 responses and back off exponentially if encountered.
|
||||
|
||||
3. **iOS Push reliability (LOW risk for this use case):** iOS 16.4+ supports Web Push only when the PWA is installed to the home screen. The wife will be asked to install the PWA as part of onboarding. Canada is not in the EU, so the iOS 17.4 DMA PWA restriction does not apply. Push is a "nice to have" alarm, not a safety-critical channel — missed notifications are acceptable.
|
||||
|
||||
## Sources
|
||||
|
||||
- [Fastmail API Documentation](https://www.fastmail.com/dev/) — CalDAV supported, JMAP calendars pending RFC finalization
|
||||
- [Fastmail: Calendar Sharing with Other Users](https://www.fastmail.help/hc/en-us/articles/1500000279781-Sharing-calendars-with-other-users) — ACL tiers confirmed; cross-account token access not documented
|
||||
- [Fastmail: Shared Calendaring Improvements](https://www.fastmail.com/blog/shared-calendar-improvements/) — CalDAV ACL standards alignment, per-user reminders
|
||||
- [Fastmail: App Passwords](https://www.fastmail.help/hc/en-us/articles/360058752854-App-passwords) — CalDAV uses app passwords, not API tokens
|
||||
- [Using Fastmail with CalDAV libraries](https://utf9k.net/blog/fastmail-caldav/) — Principal URL format, Cyrus IMAP internals
|
||||
- [Authelia OIDC Claims](https://www.authelia.com/integration/openid-connect/openid-connect-1.0-claims/) — iss+sub as stable identity anchor
|
||||
- [CalDAV ctag Extension](https://github.com/apple/ccs-calendarserver/blob/master/doc/Extensions/caldav-ctag.txt) — ctag-based efficient polling
|
||||
- [rrule.js](https://github.com/jkbrzt/rrule) — Client-side RRULE expansion
|
||||
- [web-push npm](https://github.com/web-push-libs/web-push) — VAPID push sender
|
||||
- [PWA iOS Push Limitations 2025](https://www.magicbell.com/blog/pwa-ios-limitations-safari-support-complete-guide) — iOS 16.4+ push support, EU exception
|
||||
- [Redis Pub/Sub + WebSocket pattern](https://ably.com/blog/scaling-pub-sub-with-websockets-and-redis) — Architecture reference
|
||||
|
||||
---
|
||||
*Architecture research for: FamilySync — self-hosted family calendar + list hub*
|
||||
*Researched: 2026-06-03*
|
||||
@@ -0,0 +1,212 @@
|
||||
# Feature Research
|
||||
|
||||
**Domain:** Self-hosted family organization hub — shared calendar + shared collaborative lists
|
||||
**Researched:** 2026-06-03
|
||||
**Confidence:** HIGH (table stakes and pitfalls well-evidenced across multiple products; differentiators MEDIUM — scoped to 2-person self-hosted context)
|
||||
|
||||
---
|
||||
|
||||
## Feature Landscape
|
||||
|
||||
### Table Stakes (Users Expect These)
|
||||
|
||||
Features that must exist on day one. Missing any of these makes the product feel broken, not incomplete.
|
||||
|
||||
| Feature | Why Expected | Complexity | Notes |
|
||||
|---------|--------------|------------|-------|
|
||||
| Unified multi-calendar view | Core value — see all schedules at once | MEDIUM | Aggregating Fastmail shared + personal calendars via JMAP/CalDAV broker token. Color per calendar/member is the visual primitive the whole product depends on. |
|
||||
| Per-member color coding | Cannot tell whose event is whose without it | LOW | Assign a color per user in app config; render all events in that color regardless of source calendar. |
|
||||
| Day / week / month / agenda views | All competing apps offer these; absence is jarring | MEDIUM | Month view is the hardest (event overflow, multi-day spanning). Agenda view is easiest. Week view is most-used daily driver. |
|
||||
| Create / edit / delete events | Read-only calendar is not a calendar app | HIGH | Write-back to the correct Fastmail CalDAV calendar via broker token. Recurring event edits are the hard part (see dependency notes). |
|
||||
| All-day events | School holidays, birthdays, anniversaries | LOW | CalDAV `DATE` vs `DATETIME` distinction. Visual banner across top of day/week grid. |
|
||||
| Recurring events (create + display) | Weekly team standups, recurring chores, birthdays | HIGH | RRULE parsing/expansion is deceptively complex. DST handling, exception dates (EXDATE), single-instance modification (RECURRENCE-ID) are all non-trivial. Must use a library (rrule.js or equivalent). |
|
||||
| Event reminders / push notifications | Time-sensitive alerts are the whole point of a calendar | HIGH | Web Push must be wired in from the start. iOS requires PWA installed to Home Screen; service worker reliability post-device-restart is known to be fragile. Needs fallback strategy (see Pitfalls). |
|
||||
| Shared list create / check-off / reorder | Grocery list is one of the two primary list use cases | LOW | Simple CRUD in MariaDB. Checkbox state toggle + drag-to-reorder. |
|
||||
| Live list co-edit sync | Both members shop simultaneously; must not diverge | MEDIUM | WebSocket (preferred) or SSE for push. Optimistic updates in UI; server reconciliation. Redis pub/sub if multi-instance is ever needed — not needed for single-host Docker. |
|
||||
| Multiple named lists | Groceries and gift ideas are different lists | LOW | A `lists` table with name; items reference list_id. |
|
||||
| OIDC / SSO login (Authelia) | App must not have its own auth system | MEDIUM | OIDC confidential client flow. Session management. Token refresh. Wife must be able to log in without understanding what OAuth is. |
|
||||
| PWA installability (Add to Home Screen) | Native-app feel without App Store friction | MEDIUM | Web app manifest, service worker, HTTPS. iOS Safari and Chrome Android have slightly different installation prompts. Icon and splash screen assets required. |
|
||||
| Low-friction onboarding | Wife adoption is a hard constraint | LOW (UX) / MEDIUM (infra) | One URL → login via Authelia → installed PWA. No calendar credentials to enter. No separate account to create. The OIDC flow is the primary risk — it must feel seamless. |
|
||||
|
||||
---
|
||||
|
||||
### Differentiators (Competitive Advantage for This Product)
|
||||
|
||||
Features where this product can outperform commercial alternatives specifically because it is self-hosted, private, and purpose-built for exactly two people.
|
||||
|
||||
| Feature | Value Proposition | Complexity | Notes |
|
||||
|---------|-------------------|------------|-------|
|
||||
| No ads, no freemium walls | Every commercial app (Cozi, TimeTree, Maple) gates useful features behind paid tiers; Cozi restricts free users to 30-day history | LOW (operational cost) | Self-hosted means no monetization pressure. Zero marginal cost per feature. |
|
||||
| Full personal calendar overlay | Skylight/Cozi only show a shared family calendar; this app aggregates shared + each member's personal Fastmail calendars into one view | MEDIUM | Requires Fastmail calendar sharing ACLs to be configured so the broker token can read both personal calendars. The "Skylight magic" per PROJECT.md. |
|
||||
| Privacy — data stays home | Commercial apps store your family's schedule on their servers | LOW (architecture choice) | No data leaves the home network except via the Pangolin tunnel the household already controls. |
|
||||
| Tailored to exactly two users | Commercial apps design for 4–6 family members with kids; complexity of permissions, chores, kids accounts is irrelevant overhead | LOW (scope reduction) | No "family manager" role, no parental controls, no per-member permission tiers. Two equals. |
|
||||
| Event change notifications | Google Family Calendar explicitly does not send notifications when a member creates/edits an event — a documented pain point | MEDIUM | Web Push on list changes AND calendar changes. "Wife added something to the grocery list" push. "Event was changed" push. |
|
||||
| Optimistic list UX (instant check-off) | OurGroceries is praised specifically for instant sync on check-off; most apps lag | MEDIUM | Optimistic update in React state, WebSocket confirmation, rollback on failure. |
|
||||
|
||||
---
|
||||
|
||||
### Anti-Features (Deliberately Exclude)
|
||||
|
||||
Features that appear in commercial products but are wrong for a two-person self-hosted household. Building these would bloat scope without providing value.
|
||||
|
||||
| Feature | Why Commercial Apps Have It | Why to Exclude | What to Do Instead |
|
||||
|---------|----------------------------|-----------------|--------------------|
|
||||
| Chores / rewards / star system | Skylight's chore-chart is a primary SKU driver; kid motivation is a multi-child household need | Zero children in this household. Chores as a product concept doesn't exist here. | Lists serve any "task" need. A grocery list is a chore list if you want it to be. |
|
||||
| Meal planning / recipe box | Cozi, FamCal, Maple, Skylight all have it; drives DAU | Adds a distinct domain (recipes, ingredients, nutrition) with high implementation cost. A family calendar + grocery list serves 90% of the coordination need without a recipe database. | Add grocery items manually or via list. If meal planning is ever wanted, it's a separate v3 concern. |
|
||||
| Kids / sub-accounts without email | FamCal's differentiator — create accounts for children | No children; irrelevant | N/A |
|
||||
| AI email-to-event import | Sense's primary differentiator; Skylight's Magic Import | Requires email access (out of scope), an LLM backend, and ongoing maintenance. Privacy risk. | Create events manually. Event creation UX should be fast enough that manual entry isn't painful. |
|
||||
| RSVP / event invite flows | Used in apps targeting external coordination | For a two-person household sharing one calendar, RSVP is moot — both members see all events by default. CalDAV RSVP (iTIP/iMIP) is a substantial protocol on top of the calendar work. | Both users always attend shared events. Personal calendar events are visible but don't need RSVP. |
|
||||
| Event-level comments / photos (TimeTree-style) | TimeTree's differentiator; useful for larger groups coordinating event details | Two people can just text each other. Adds a chat/media system with storage for near-zero incremental value. | Use SMS/iMessage for event-level discussion as now. |
|
||||
| Activity feed / audit log | TimeTree, some Cozi Gold features | Useful when you need to know which of 5 family members deleted the dentist appointment. With two users it's obvious. | N/A |
|
||||
| Accounts at scale / multi-household | Commercial apps target 4–6 household members, sometimes multiple households | One household, two users. Multi-tenant adds auth and data isolation complexity for zero gain. | Hardcode exactly two accounts in Authelia. |
|
||||
| Ads / monetization | Cozi free tier is ad-supported | Self-hosted; no revenue model needed | N/A |
|
||||
| Complex permissions / role tiers | "Family manager", read-only members, etc. | Two equal partners. | Both users have identical write access to all calendars and lists. |
|
||||
| Offline-first with full conflict resolution | Required for apps targeting users with spotty connectivity | Home WiFi + PWA is the primary use surface. Brief offline tolerance (optimistic updates + retry) is sufficient. CRDTs and full offline sync are engineering overhead without commensurate benefit. | Optimistic updates + graceful "offline" indicator. Retry on reconnect. |
|
||||
| Push-to-native-calendar (CalDAV subscribe URL) | Useful for Apple Calendar native integration | Optional, not v1. The PWA is the primary interface. Native calendar subscribe is a nice-to-have for the wife if she wants it — document it, don't build UI for it. | CalDAV subscribe URL for Fastmail calendars already works natively; just document how to set it up. |
|
||||
| Grocery delivery integration (Instacart, etc.) | Maple's differentiator | Third-party API dependency; not needed when the family handles their own shopping | N/A |
|
||||
|
||||
---
|
||||
|
||||
## Feature Dependencies
|
||||
|
||||
```
|
||||
[OIDC Login]
|
||||
└──required by──> [All other features] (nothing works without auth)
|
||||
|
||||
[CalDAV/JMAP broker token]
|
||||
└──required by──> [Calendar read]
|
||||
└──required by──> [Unified calendar view]
|
||||
└──required by──> [Per-member color coding]
|
||||
└──required by──> [Day/week/month views]
|
||||
└──required by──> [Event create/edit/delete]
|
||||
└──required by──> [All-day events] (DATE type)
|
||||
└──required by──> [Recurring event display]
|
||||
└──required by──> [Recurring event edit]
|
||||
(RECURRENCE-ID, EXDATE — hardest sub-feature)
|
||||
|
||||
[Web Push registration]
|
||||
└──required by──> [Event reminders]
|
||||
└──required by──> [List change notifications]
|
||||
└──enhances──> [Live list sync] (push as fallback to WebSocket on reconnect)
|
||||
|
||||
[PWA installability]
|
||||
└──required by──> [Web Push on iOS] (iOS only delivers push to installed PWAs)
|
||||
└──required by──> [Low-friction onboarding] (one URL → installed app)
|
||||
|
||||
[MariaDB lists schema]
|
||||
└──required by──> [Named lists]
|
||||
└──required by──> [List items CRUD]
|
||||
└──required by──> [Check-off / reorder]
|
||||
└──enhanced by──> [Live list sync via WebSocket]
|
||||
|
||||
[Service worker]
|
||||
└──required by──> [PWA installability]
|
||||
└──required by──> [Web Push]
|
||||
└──enhances──> [Offline tolerance] (cache shell, retry queue)
|
||||
```
|
||||
|
||||
### Dependency Notes
|
||||
|
||||
- **OIDC login must come first.** Everything else is gated on auth. The OIDC flow with Authelia must be smooth enough that the non-technical member can complete it once, then never see it again (persistent session).
|
||||
- **CalDAV/JMAP broker token is the calendar foundation.** All calendar features depend on proving this integration works reliably before building display or editing UI on top of it.
|
||||
- **Recurring events require a library.** Implementing RRULE expansion manually is impractical. Use rrule.js (frontend) and a server-side equivalent for reminder scheduling. Single-instance edits (RECURRENCE-ID) and "this and following" edits add significant complexity and should be scoped carefully — basic recurring create/display can ship before full edit support.
|
||||
- **PWA install is a prerequisite for iOS Web Push.** Web Push on iOS does not work from a Safari tab — only from an installed PWA. This means the install step is not optional for the wife to receive notifications. The onboarding flow must guide her through Add to Home Screen.
|
||||
- **Live list sync requires WebSocket infrastructure.** This is a dependency on the server-side connection management (Socket.io or native WS). Redis pub/sub is only needed if the backend ever runs as multiple instances — not relevant for single-Docker-host deployment.
|
||||
|
||||
---
|
||||
|
||||
## MVP Definition
|
||||
|
||||
### Launch With (v1)
|
||||
|
||||
- [ ] OIDC login via Authelia — required for everything else; must be seamless for non-technical user
|
||||
- [ ] CalDAV/JMAP broker integration — read Fastmail calendars (shared + personal)
|
||||
- [ ] Unified calendar view with per-member colors — day, week, month views
|
||||
- [ ] Create / edit / delete events (write-back to Fastmail) — all-day and timed; recurring create/display; single-instance edit is a stretch goal
|
||||
- [ ] Shared lists — create named list, add/check/reorder items, delete items
|
||||
- [ ] Live list sync via WebSocket — both members co-edit in real time
|
||||
- [ ] Web Push notifications — event reminders, list change alerts
|
||||
- [ ] PWA manifest + service worker — installable on iPhone and Android
|
||||
- [ ] Guided Add to Home Screen prompt on first visit (iOS)
|
||||
|
||||
### Add After Validation (v1.x)
|
||||
|
||||
- [ ] Recurring event single-instance edit (RECURRENCE-ID) — add after core recurring display is stable and tested
|
||||
- [ ] "This and following" recurring edit — complex; only add if users report the need
|
||||
- [ ] Native CalDAV subscribe URL documentation — wife can optionally add to Apple Calendar; no new code needed, just documented
|
||||
- [ ] Timezone display toggle — show events in a secondary timezone if the household ever travels across zones
|
||||
|
||||
### Future Consideration (v2+)
|
||||
|
||||
- [ ] Wall-display / kiosk dashboard — per PROJECT.md, explicitly deferred to v2
|
||||
- [ ] Upcoming events widget / agenda summary — nice home screen widget-style view for the display
|
||||
- [ ] Calendar event color override per event — current plan is color per member; per-event override adds UI complexity
|
||||
|
||||
---
|
||||
|
||||
## Feature Prioritization Matrix
|
||||
|
||||
| Feature | User Value | Implementation Cost | Priority |
|
||||
|---------|------------|---------------------|----------|
|
||||
| OIDC login | HIGH | MEDIUM | P1 |
|
||||
| CalDAV/JMAP broker | HIGH | HIGH | P1 |
|
||||
| Unified calendar view (read) | HIGH | MEDIUM | P1 |
|
||||
| Per-member color coding | HIGH | LOW | P1 |
|
||||
| Day / week / month views | HIGH | MEDIUM | P1 |
|
||||
| Event create/edit/delete | HIGH | HIGH | P1 |
|
||||
| All-day events | HIGH | LOW | P1 |
|
||||
| Recurring events (create + display) | HIGH | HIGH | P1 |
|
||||
| Shared lists CRUD | HIGH | LOW | P1 |
|
||||
| Live list sync (WebSocket) | HIGH | MEDIUM | P1 |
|
||||
| Web Push notifications | HIGH | HIGH | P1 |
|
||||
| PWA installability | HIGH | MEDIUM | P1 |
|
||||
| List change notifications | MEDIUM | LOW | P1 (shares push infra) |
|
||||
| Recurring event single-instance edit | MEDIUM | HIGH | P2 |
|
||||
| "This and following" recurring edit | LOW | HIGH | P3 |
|
||||
| Native CalDAV subscribe docs | LOW | LOW | P2 |
|
||||
| Timezone display toggle | LOW | MEDIUM | P3 |
|
||||
| Wall-display kiosk view | MEDIUM | MEDIUM | P3 (v2) |
|
||||
|
||||
---
|
||||
|
||||
## Competitor Feature Analysis
|
||||
|
||||
| Feature | Skylight | Cozi | TimeTree | Google Family | This Product |
|
||||
|---------|----------|------|----------|---------------|--------------|
|
||||
| Color per member | Yes | Yes | Yes | No | Yes |
|
||||
| Personal + shared calendar overlay | No (shared only) | No | No | No | Yes (Fastmail aggregation) |
|
||||
| Multiple calendar views | Yes | Partial (Gold gates month) | Yes | Yes | Yes |
|
||||
| Recurring events | Yes | Yes | Yes | Yes | Yes (display v1; full edit v1.x) |
|
||||
| Shared lists | Yes | Yes | No | No | Yes |
|
||||
| Live list sync | Unknown | Yes | N/A | N/A | Yes (WebSocket) |
|
||||
| Push notifications on change | Yes | Partial | Yes | No | Yes |
|
||||
| Event-level comments | No | No | Yes | No | No (anti-feature) |
|
||||
| Chores / rewards | Yes (primary feature) | Yes | No | No | No (anti-feature) |
|
||||
| Meal planning | Yes | Yes | No | No | No (anti-feature) |
|
||||
| AI import | Yes (Magic Import) | No | No | No | No (anti-feature) |
|
||||
| RSVP | No | No | No | No | No (anti-feature) |
|
||||
| Self-hosted / private | No | No | No | No | Yes (differentiator) |
|
||||
| No ads / no paywall | No (Plus plan) | No (Gold plan) | No (Premium) | Yes | Yes |
|
||||
| Cross-ecosystem (iOS + Android) | App + hardware | App | App | App | PWA (single URL) |
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
- [Skylight Calendar product page](https://myskylight.com/calendar/)
|
||||
- [Skylight Calendar 2 TechCrunch review, Jan 2026](https://techcrunch.com/2026/01/07/skylight-debuts-calendar-2-to-keep-your-family-organized/)
|
||||
- [Cozi feature overview](https://www.cozi.com/feature-overview/)
|
||||
- [Cozi Gold features](https://www.cozi.com/cozi-gold-features/)
|
||||
- [Maple best family calendar app comparison](https://www.growmaple.com/blog-posts/best-family-calendar-app)
|
||||
- [Best Family Calendar Apps 2026 — getsense.ai](https://getsense.ai/blog/posts/best-family-calendar-apps-2026)
|
||||
- [TimeTree review and features](https://toolstack.io/tools/timetree)
|
||||
- [Google Family Calendar — support.google.com](https://support.google.com/families/answer/7157782)
|
||||
- [Nylas: The Deceptively Complex World of RRULEs](https://www.nylas.com/blog/calendar-events-rrules/)
|
||||
- [Mozilla Wiki: Calendar Recurrence and Exceptions](https://wiki.mozilla.org/Calendar:Recurrence_and_Exceptions)
|
||||
- [PWA iOS limitations 2026 — magicbell.com](https://www.magicbell.com/blog/pwa-ios-limitations-safari-support-complete-guide)
|
||||
- [PWA push notifications iOS — OneSignal docs](https://documentation.onesignal.com/docs/en/web-push-for-ios)
|
||||
- [Real-time data sync with WebSockets — GTCSys](https://gtcsys.com/real-time-communication-in-pwas-websockets-server-sent-events-and-webrtc/)
|
||||
|
||||
---
|
||||
*Feature research for: FamilySync — self-hosted family organization hub*
|
||||
*Researched: 2026-06-03*
|
||||
@@ -0,0 +1,558 @@
|
||||
# Pitfalls Research
|
||||
|
||||
**Domain:** Fastmail-brokered family calendar + shared-list PWA with Web Push, self-hosted behind Authelia
|
||||
**Researched:** 2026-06-03
|
||||
**Confidence:** MEDIUM-HIGH (CalDAV/RRULE/iOS push well-documented in community; Fastmail-specific rate limits and JMAP calendar status confirmed from official docs)
|
||||
|
||||
---
|
||||
|
||||
## Critical Pitfalls
|
||||
|
||||
### Pitfall 1: Fastmail Calendars Are CalDAV-Only — JMAP Calendar Is Not Production-Ready
|
||||
|
||||
**What goes wrong:**
|
||||
Fastmail's developer documentation explicitly states: "Calendars — you can access via CalDAV. We will be opening up JMAP access as well, as soon as the specification is finalized." If you build your calendar broker against JMAP expecting calendar read/write, you will find either no endpoint or an unstable/draft surface. This kills the entire broker strategy.
|
||||
|
||||
**Why it happens:**
|
||||
JMAP for Calendars (RFC draft) has been "almost finalized" for years. It's easy to assume Fastmail's JMAP support is comprehensive because their email/contacts JMAP support is excellent. Calendar is the exception.
|
||||
|
||||
**How to avoid:**
|
||||
Commit to CalDAV as the protocol for all calendar read/write. Do not design the broker around JMAP calendars or leave it open. Use `https://caldav.fastmail.com/dav/principals/user/{email}/` as the base URL — the bare `caldav.fastmail.com` root is not sufficient for library URL discovery.
|
||||
|
||||
**Warning signs:**
|
||||
- Any design doc that says "CalDAV or JMAP, TBD"
|
||||
- Libraries that prefer JMAP and fall back silently
|
||||
|
||||
**Phase to address:**
|
||||
Calendar broker implementation phase (first phase that touches Fastmail). Lock the protocol decision in the first spike; do not re-evaluate.
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 2: Recurring Event RRULE Expansion Done in the App Instead of Leveraged from Server
|
||||
|
||||
**What goes wrong:**
|
||||
The app fetches VCALENDAR objects and attempts to expand RRULE recurrences in application code using a generic JS library (e.g., `rrule.js`). The RFC 5545 recurrence model is large: RRULE, RDATE, EXDATE, and RECURRENCE-ID overrides interact. Bugs appear in: yearly events near leap-day, monthly BYDAY rules (e.g., "last Friday"), weekly events near DST transitions, and any series with moved/cancelled individual instances (RECURRENCE-ID).
|
||||
|
||||
**Why it happens:**
|
||||
Developers underestimate RFC 5545 complexity. Square built an internal RRULE library because existing ones couldn't handle the full spec. Even `rrule.js` has known edge cases with DST and complex rules. The CalDAV spec provides server-side expansion (CALDAV:expand) exactly because client expansion is error-prone.
|
||||
|
||||
**How to avoid:**
|
||||
Use CalDAV's server-side expansion. Issue time-ranged REPORT requests with `<C:expand>` to get back already-expanded instances within a window, rather than fetching raw VCALENDAR and expanding yourself. Only expand locally for display rendering (simple cases). Use `rrule.js` only for the display layer on events you've already confirmed against server expansion.
|
||||
|
||||
**Warning signs:**
|
||||
- Events that appear correct in simple cases but shift by one hour across DST boundaries
|
||||
- A recurring weekly event showing on the wrong day for specific months
|
||||
- Cancelled or moved instances reappearing
|
||||
|
||||
**Phase to address:**
|
||||
Calendar fetch/display phase. Define the REPORT request format in the first calendar sync spike, not as a later optimization.
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 3: All-Day Events Interpreted as Timed UTC Events
|
||||
|
||||
**What goes wrong:**
|
||||
All-day events in iCalendar use `DATE` (not `DATETIME`) values and carry no timezone. If your backend stores or returns them as UTC datetimes, or if the frontend uses `.toISOString()` on the date, an all-day event for "June 5" becomes "June 4 at 20:00 PDT" — it shifts into the previous day. The CalDAV time-range filter also returns wrong results for all-day events when UTC arithmetic is applied.
|
||||
|
||||
**Why it happens:**
|
||||
Most date libraries default to UTC datetime handling. The distinction between `DTSTART;VALUE=DATE:20260605` and `DTSTART;TZID=America/Toronto:20260605T090000` is easy to conflate. Home Assistant's CalDAV integration has had this exact bug filed multiple times.
|
||||
|
||||
**How to avoid:**
|
||||
Represent all-day events as a `{ date: "YYYY-MM-DD", allDay: true }` struct throughout the stack — never coerce to a JS `Date` or SQL `DATETIME`. In MariaDB, store as `DATE` column, not `DATETIME`. In the API response, emit the ISO date string without a time component. In the frontend, detect `allDay` and render accordingly without any timezone conversion.
|
||||
|
||||
**Warning signs:**
|
||||
- All-day events appearing on the day before in certain timezones
|
||||
- CalDAV time-range queries missing all-day events that should be in range
|
||||
|
||||
**Phase to address:**
|
||||
Calendar data model phase. Define the `allDay` field in the internal schema before writing any persistence or API code.
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 4: ETag / Sync-Token Incremental Sync Done Wrong
|
||||
|
||||
**What goes wrong:**
|
||||
Two common failure modes. First: the app does a full PROPFIND on every poll instead of using WebDAV-Sync (RFC 6578), burning bandwidth and causing delays at scale. Second: the app uses sync-tokens but doesn't handle 403 (token expired/forgotten by server) — some servers, including iCloud, will drop old tokens, and the app must fall back to full resync. Fastmail uses Cyrus IMAP under the hood and can expire tokens.
|
||||
|
||||
For write operations: the app updates a VCALENDAR without sending `If-Match: <etag>`, so if the event was concurrently modified (e.g., from the primary user's native Fastmail app), the server returns a 412 and the app either silently drops the write or throws an unhandled error.
|
||||
|
||||
**How to avoid:**
|
||||
- Always check for `{DAV}sync-token` support via PROPFIND before using it; fall back to getctag polling if absent
|
||||
- Persist the last sync-token in the database and use it on subsequent syncs
|
||||
- Handle 403 on sync-token by discarding the token and doing a full resync
|
||||
- On all PUT/DELETE operations, include `If-Match: <etag>` header; handle 412 by fetching the current state, presenting a merge/overwrite choice (even if just "last write wins" for v1)
|
||||
|
||||
**Warning signs:**
|
||||
- Polling logs show full PROPFIND responses every cycle rather than delta responses
|
||||
- Errors after periods of inactivity that clear on app restart
|
||||
- Edits from the native Fastmail app not appearing or being overwritten silently
|
||||
|
||||
**Phase to address:**
|
||||
Calendar sync engine phase. The sync-token + ETag strategy must be in the design before writing the poller — retrofitting is painful.
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 5: Write-Back Modifying a Single Recurring Instance Corrupts the Series
|
||||
|
||||
**What goes wrong:**
|
||||
When a user edits a single occurrence of a recurring event (e.g., moves next Tuesday's meeting to Wednesday), the correct CalDAV write is to store a VEVENT with `RECURRENCE-ID` as an additional component in the same VCALENDAR resource. A naive implementation either: (a) writes a new standalone event, leaving the original occurrence intact (duplication), or (b) modifies the master RRULE, changing all future occurrences.
|
||||
|
||||
Known Nextcloud and ownCloud bugs document exactly this: "CalDAV: Moving single event from recurring events results in duplicated event."
|
||||
|
||||
**How to avoid:**
|
||||
When editing a single instance: fetch the full VCALENDAR, inject a new VEVENT block with `RECURRENCE-ID` matching the original instance's DTSTART, and PUT the entire modified VCALENDAR back with `If-Match`. Do not create a new resource. If editing all future instances, set `UNTIL` or `COUNT` on the master rule and create a new recurring series starting from the edit point.
|
||||
|
||||
For v1, consider restricting to "edit all instances" only and deferring single-instance overrides to v2 — the complexity is disproportionate to a two-person household.
|
||||
|
||||
**Warning signs:**
|
||||
- Editing a recurring event produces two events on the calendar
|
||||
- Other instances of the series shift after an individual edit
|
||||
- Events with `RECURRENCE-ID` appearing as standalone items
|
||||
|
||||
**Phase to address:**
|
||||
Event edit UI phase. The decision to support or defer single-instance overrides must be made before the edit form is built.
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 6: DST Transition Shifts Recurring Events by One Hour
|
||||
|
||||
**What goes wrong:**
|
||||
A recurring event created in summer (UTC-4) that spans a DST boundary (clocks fall back to UTC-5) can shift by one hour on every occurrence after the transition if the VTIMEZONE component is malformed or if the server/client disagrees on which TZID to use. This is the single most-reported CalDAV bug across all implementations.
|
||||
|
||||
**Why it happens:**
|
||||
The iCalendar spec requires a VTIMEZONE block describing the DST rules for the TZID in use. Many libraries emit a minimal or incorrect VTIMEZONE. Fastmail's Cyrus server uses its own TZID database; if the client sends a different TZID alias (e.g., `Eastern Standard Time` vs `America/New_York`), the server may misinterpret transitions.
|
||||
|
||||
**How to avoid:**
|
||||
- Always use IANA timezone IDs (e.g., `America/Toronto`) — never Windows-style IDs
|
||||
- Use a library that generates correct VTIMEZONE blocks from the IANA tz database (e.g., `ical.js` or `node-ical` with `tz-data`)
|
||||
- Test recurring events specifically across the spring and fall DST boundary dates before any calendar milestone is considered done
|
||||
|
||||
**Warning signs:**
|
||||
- Events correct in summer that shift by exactly one hour in November
|
||||
- `TZID` values in emitted iCalendar containing spaces or Windows timezone names
|
||||
|
||||
**Phase to address:**
|
||||
Calendar write phase. Add a DST-crossing test fixture before the first release.
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 7: Personal Calendar Sharing to the Broker Token Is Not Automatic
|
||||
|
||||
**What goes wrong:**
|
||||
The architecture assumes one broker API token reads all calendars (shared family + each member's personal). But a Fastmail API token scoped to the primary account cannot read a different member's personal Fastmail calendar unless that calendar has been explicitly shared using Fastmail's CalDAV sharing model (offer + acceptance flow). This is not automatic; it requires setup steps that involve both Fastmail accounts.
|
||||
|
||||
**Why it happens:**
|
||||
Fastmail's calendar ACLs follow the CalDAV sharing standard: the owner must share the calendar, and the recipient must accept. A single-account API token only sees calendars in that account's homeset plus calendars shared to it and accepted.
|
||||
|
||||
Additionally, the wife's personal calendar may not be on Fastmail at all if she uses Apple Calendar as her primary (iCloud calendar). In that case the broker can never read it via CalDAV — there is no cross-service token.
|
||||
|
||||
**How to avoid:**
|
||||
- Do a proof-of-concept share in the first calendar spike: share the primary user's personal calendar to a test account, accept it, and verify the broker token sees it via PROPFIND of the homeset
|
||||
- Document the manual setup steps for each member's personal calendar as part of the deployment runbook
|
||||
- For v1, scope the MVP to the shared family calendar only; add personal calendar overlay only after confirming the share/accept flow works
|
||||
- If the wife's personal events live in iCloud (not Fastmail), treat her personal calendar as out of scope or accept an ICS subscription URL approach
|
||||
|
||||
**Warning signs:**
|
||||
- Broker token PROPFIND returns only the shared family calendar, not personal calendars
|
||||
- Empty calendar list after adding a member
|
||||
|
||||
**Phase to address:**
|
||||
Infrastructure/deployment phase and calendar broker spike. This is a prerequisite that blocks "unified view" features.
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 8: iOS Web Push Requires "Add to Home Screen" — and Apple Provides No Install Prompt
|
||||
|
||||
**What goes wrong:**
|
||||
On Android, Chrome shows an "Install" banner or button (beforeinstallprompt). On iOS, there is no equivalent browser prompt. The user must manually tap the Safari share sheet, scroll to find "Add to Home Screen," and tap it. A non-technical user who doesn't know this exists will never install the PWA, and therefore will never receive any push notifications.
|
||||
|
||||
**Why it happens:**
|
||||
Apple has explicitly not implemented the Web App Manifest install prompt on iOS. The Add to Home Screen path exists but is discoverable only if you know where to look.
|
||||
|
||||
**How to avoid:**
|
||||
- Implement an in-app installation guide with annotated screenshots specific to iOS Safari (share icon → "Add to Home Screen") that appears on first visit when `display-mode: browser` is detected
|
||||
- Use `navigator.standalone` to detect whether the app is installed and conditionally show the banner
|
||||
- Do not assume the wife will find this herself — the onboarding flow for iOS must walk her through it explicitly
|
||||
|
||||
**Warning signs:**
|
||||
- No push subscriptions registered for iPhone users
|
||||
- Wife accessing the app via browser tab URL, not from the home screen
|
||||
|
||||
**Phase to address:**
|
||||
PWA setup / onboarding phase. The install guide is not a nice-to-have — it is load-bearing for the non-technical user UX constraint.
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 9: iOS Kills Push Subscriptions Silently After 3 Silent Pushes
|
||||
|
||||
**What goes wrong:**
|
||||
Apple/WebKit enforces `userVisibleOnly: true` strictly. If the service worker receives a push event and fails to display a notification before the event handler terminates — even once accidentally — iOS counts it as a "silent push." After 3 silent pushes, the subscription is permanently revoked without any `pushsubscriptionchange` event (which iOS doesn't support anyway). The user stops receiving notifications without knowing it, and the server continues sending to a dead endpoint.
|
||||
|
||||
**Why it happens:**
|
||||
The most common mistake is calling `showNotification()` without wrapping it in `event.waitUntil()`. Without `waitUntil`, the service worker runtime terminates before the async notification display completes, making it appear silent to iOS.
|
||||
|
||||
Separately: Apple's Intelligent Tracking Prevention (ITP) deletes service worker registrations for sites not visited "recently enough," silently invalidating subscriptions.
|
||||
|
||||
**How to avoid:**
|
||||
- Always wrap `showNotification()` in `event.waitUntil()` — no exceptions
|
||||
- Implement a subscription health-check: on every app open, call `pushManager.getSubscription()` and compare the endpoint to the stored server-side endpoint; re-subscribe if they differ or if null
|
||||
- On the server, handle 410 (Gone) responses from the push service as permanent subscription deletion; remove the subscription record immediately
|
||||
- Handle 404 and 401 from the push service as potentially expired; remove and force re-subscription on next app open
|
||||
- Log push delivery success/failure server-side so silent failures are detectable
|
||||
|
||||
**Warning signs:**
|
||||
- Push success rate drops from the server perspective with no user-visible errors
|
||||
- Server has endpoint records but delivers 410/404
|
||||
- Wife's iPhone stops getting notifications after a week of inactivity
|
||||
|
||||
**Phase to address:**
|
||||
Push notification implementation phase. The `waitUntil` pattern and subscription health-check must be in the initial implementation, not added later.
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 10: Declarative Web Push vs Standard Web Push — Choose the Right Target
|
||||
|
||||
**What goes wrong:**
|
||||
WebKit has introduced "Declarative Web Push" (announced mid-2025), which allows notification display without a service worker by using a standardized JSON payload format. If you build against standard Web Push with a service worker and custom payload parsing, and then Apple's ITP clears the service worker, your notifications stop. Declarative Web Push survives ITP because the browser can display the notification natively without running JS.
|
||||
|
||||
If you build notifications that depend on custom payload processing in the service worker (e.g., fetching additional data from the server before showing the notification), Declarative Web Push cannot handle that — you'd need the service worker anyway.
|
||||
|
||||
**How to avoid:**
|
||||
Design notification payloads to be self-contained (all display data in the push payload: title, body, icon, URL to open). This satisfies both Declarative Web Push display requirements and standard Web Push service worker display. Do not design a "fetch-to-display" pattern where the service worker hits the API before showing anything — that pattern breaks on iOS after ITP clears the SW.
|
||||
|
||||
**Warning signs:**
|
||||
- Notification payloads that contain only an event ID, requiring a network fetch to render
|
||||
- Service worker push handler making API calls before `showNotification`
|
||||
|
||||
**Phase to address:**
|
||||
Push notification design phase. Define the payload schema before implementing the server-side push sender.
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 11: EU Digital Markets Act Breaks iOS PWA Entirely for EU Users
|
||||
|
||||
**What goes wrong:**
|
||||
Since iOS 17.4 (March 2024), in EU countries Apple removed standalone PWA mode under the Digital Markets Act. PWAs open as standard Safari tabs. Push notifications do not work. Add to Home Screen produces a bookmark, not an installed PWA. This affects the entire notification strategy for any EU household.
|
||||
|
||||
**Why it matters here:**
|
||||
The project is Canadian (primary user email: me@lucasberger.ca — `.ca` domain, Unraid self-hosted). If the household is in Canada, this does not apply. Document it as a known non-issue for this deployment, but note it if the household ever relocates or if devices are registered in EU Apple IDs.
|
||||
|
||||
**How to avoid:**
|
||||
Confirm Apple ID region for both household members. If non-EU, proceed without mitigation. If EU, the entire push notification strategy must shift to email or in-app alerts only.
|
||||
|
||||
**Phase to address:**
|
||||
Risk assessment before push implementation. One-time check, not ongoing work.
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 12: Service Worker Caching Serves Stale Calendar Data
|
||||
|
||||
**What goes wrong:**
|
||||
If the service worker uses a Cache-First strategy for API responses, calendar data shown in the PWA may be hours old. A user adds an event from the native Fastmail app, opens FamilySync, and sees yesterday's calendar. For a family coordination tool this destroys trust immediately.
|
||||
|
||||
**Why it happens:**
|
||||
Cache-First is the default recommendation for PWA shell (HTML/CSS/JS assets) but gets applied to API/data routes by mistake, or by using a broad URL pattern in the Workbox config.
|
||||
|
||||
**How to avoid:**
|
||||
- Use Cache-First only for static assets (JS bundles, CSS, icons) with content-hash filenames
|
||||
- Use Network-First for all `/api/*` routes; fall back to cache only if offline
|
||||
- Use Stale-While-Revalidate for calendar data that is acceptable to be slightly stale (list data OK; calendar event times not OK)
|
||||
- Scope `workbox-recipes` patterns explicitly; never use `.*` to match API routes
|
||||
|
||||
**Warning signs:**
|
||||
- Network tab shows calendar API responses served from ServiceWorker cache
|
||||
- Events created elsewhere don't appear after page reload
|
||||
|
||||
**Phase to address:**
|
||||
PWA service worker configuration phase. Cache strategy per route must be intentional from the start.
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 13: Service Worker Update Staleness — App Never Updates for the Wife
|
||||
|
||||
**What goes wrong:**
|
||||
Safari on iOS respects the HTTP cache for service worker script fetching. If the server sends `Cache-Control: max-age=3600` for the SW script, Safari will not check for updates for an hour. For an installed PWA that the wife only opens occasionally, she can run a version that is days old. Breaking API changes in the backend will cause silent failures.
|
||||
|
||||
**Why it happens:**
|
||||
Many web servers (Nginx defaults) cache JS assets aggressively. The service worker file (`sw.js`) must be served with `Cache-Control: no-cache` or `max-age=0` specifically to ensure the browser checks for updates on each visit.
|
||||
|
||||
**How to avoid:**
|
||||
- Serve the service worker file with `Cache-Control: no-cache` header explicitly
|
||||
- Serve the web app manifest with `Cache-Control: no-cache`
|
||||
- Use Vite's default hashed filenames for all other assets (already correct)
|
||||
- Implement a "new version available" in-app prompt when the SW detects an update (`waiting` state), so the wife knows to tap "refresh"
|
||||
|
||||
**Warning signs:**
|
||||
- Deployed backend changes not reflected in the app for hours or days
|
||||
- Console shows `ServiceWorker: new service worker found, not yet activated`
|
||||
|
||||
**Phase to address:**
|
||||
PWA build configuration phase. Set the no-cache header in Docker/Nginx config before first deployment.
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 14: Calendar Cache Stale vs Fastmail Source of Truth — Double-Write Window
|
||||
|
||||
**What goes wrong:**
|
||||
The app caches Fastmail calendar data in MariaDB (or Redis) for performance. A user creates an event via the app (write to Fastmail, update local cache). The primary user simultaneously creates the same-time event from the native Fastmail app. The next poll catches the conflict, but between the write and the poll, the app's cache is wrong. If the poll interval is 5 minutes, the family sees conflicting events for up to 5 minutes.
|
||||
|
||||
A worse failure: the write to Fastmail succeeds but the cache update fails (network error mid-transaction). Now the cache is permanently wrong until the next full resync.
|
||||
|
||||
**How to avoid:**
|
||||
- Treat the cache as write-through: invalidate the relevant calendar's cache entry immediately on any write, forcing the next read to pull from Fastmail
|
||||
- After a write (PUT/POST to Fastmail), always re-fetch the created/updated object to get the server-assigned ETag and any server-side modifications
|
||||
- Never update the cache with the client's version of the object — only cache objects received from the server
|
||||
- For v1, a 60-second poll interval is acceptable. Do not optimize this prematurely.
|
||||
|
||||
**Warning signs:**
|
||||
- Event created in the app doesn't appear on the next refresh
|
||||
- Duplicate events visible for a short window
|
||||
- ETag mismatch errors on the second consecutive edit of the same event
|
||||
|
||||
**Phase to address:**
|
||||
Calendar broker / cache design phase. Write-through invalidation must be in the cache design, not patched in later.
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 15: Real-Time List Sync — Missed Updates During Reconnect Gap
|
||||
|
||||
**What goes wrong:**
|
||||
The client connects via WebSocket (or SSE). The connection drops (mobile network switch, brief outage). On reconnect, the client re-subscribes but has missed events that fired during the gap. The list appears consistent to both users but is actually diverged — one user's added item is missing from the other's view.
|
||||
|
||||
**Why it happens:**
|
||||
The reconnect handler re-subscribes from "now" rather than replaying from a sequence number or version cursor.
|
||||
|
||||
**How to avoid:**
|
||||
- Each list mutation must increment a monotonic version on the row (`updated_at` with microsecond precision is insufficient — use an explicit integer sequence per list)
|
||||
- On (re)connect, the client sends its last-known sequence; the server replays any mutations with sequence > client's last-known
|
||||
- Implement exponential backoff with jitter on reconnect (500ms base, 2x multiplier, 30s cap)
|
||||
- Redis Pub/Sub is appropriate here; if Redis is unavailable, fall back to polling every 5s
|
||||
|
||||
**Warning signs:**
|
||||
- Items added during a reconnect gap missing from one client's view
|
||||
- List state inconsistent between the two household members
|
||||
|
||||
**Phase to address:**
|
||||
Shared lists implementation phase. The sequence number column must be in the initial schema.
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 16: Authelia OIDC — v4.39+ Breaking Change Drops `groups` from ID Token
|
||||
|
||||
**What goes wrong:**
|
||||
Authelia v4.39 introduced a breaking change: the `groups` claim is no longer included in the ID token by default — it moved to the userinfo endpoint. If the backend validates authorization based on the `groups` claim in the ID token (a common pattern when following older Authelia docs), group-based access control silently stops working after an Authelia upgrade.
|
||||
|
||||
**How to avoid:**
|
||||
- Request the `groups` scope explicitly in the OIDC client config
|
||||
- Validate group membership by calling the userinfo endpoint, not by reading ID token claims
|
||||
- Or: use Authelia purely for authentication (who are you), not authorization (what can you do) — in a two-person household, all authenticated users are trusted, so groups are irrelevant for FamilySync
|
||||
|
||||
**Warning signs:**
|
||||
- Group-based middleware that worked before an Authelia upgrade stops denying unauthorized users
|
||||
- `groups` claim missing from decoded ID token
|
||||
|
||||
**Phase to address:**
|
||||
Auth integration phase. Decide whether group claims are needed at all; if not, skip them entirely.
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 17: Authelia OIDC — SPA Token Silent Renewal Breaks When Authelia Is on a Separate Domain
|
||||
|
||||
**What goes wrong:**
|
||||
The standard OIDC silent renewal technique for SPAs uses a hidden iframe that loads Authelia's authorization endpoint. If the browser blocks third-party cookies (Safari ITP does this aggressively), the iframe cannot send Authelia's session cookie, so the silent renewal returns an error and the user is redirected to the login page unexpectedly — often in a loop.
|
||||
|
||||
**Why it matters here:**
|
||||
FamilySync and Authelia may be on different subdomains (e.g., `familysync.home.domain.com` vs `auth.home.domain.com`). If they share the same registrable domain suffix (e.g., both `.home.domain.com`), same-site cookies work. If not, ITP kills the iframe flow.
|
||||
|
||||
**How to avoid:**
|
||||
- Ensure FamilySync and Authelia share the same parent domain so Authelia cookies are same-site from the browser's perspective
|
||||
- Use refresh token rotation instead of iframe-based silent renewal (Authelia supports this)
|
||||
- Configure the OIDC client in the backend (not the SPA) to hold the refresh token — the SPA calls the backend, which silently renews via the confidential client flow, and returns a new access token without iframe involvement
|
||||
- Never rely on iframe silent renewal for installed PWAs — service workers intercept the iframe navigation and it behaves unpredictably
|
||||
|
||||
**Warning signs:**
|
||||
- Users randomly logged out after token expiry with no warning
|
||||
- Infinite redirect loop between the app and Authelia login page
|
||||
- Console errors: `Failed to load resource: Frame load interrupted`
|
||||
|
||||
**Phase to address:**
|
||||
Auth integration phase. The token refresh strategy must be decided before the frontend auth client is chosen.
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 18: Pangolin/Newt Tunnel — WebSocket and SSE May Require Explicit Configuration
|
||||
|
||||
**What goes wrong:**
|
||||
Pangolin is a tunneled reverse proxy. WebSocket connections require an HTTP Upgrade handshake, and SSE (Server-Sent Events) requires long-lived HTTP connections. A generic proxy configuration that works for standard HTTP requests may silently drop WebSocket connections or close SSE streams after a timeout.
|
||||
|
||||
A known GitHub issue (#1034 on the fosrl/pangolin repo) documents exactly this: HTTP loads fine through Pangolin but WebSocket connections to `wss://` fail.
|
||||
|
||||
**How to avoid:**
|
||||
- Verify WebSocket pass-through in a dedicated infrastructure spike before building any real-time feature
|
||||
- Confirm Pangolin's timeout settings for long-lived connections and extend them appropriately
|
||||
- If WebSocket through Pangolin proves unreliable, use SSE (unidirectional, standard HTTP, more proxy-friendly) for server-to-client push and short-poll for client confirmations
|
||||
- Test the full round-trip (WebSocket from an iPhone over Pangolin) before considering real-time sync "done"
|
||||
|
||||
**Warning signs:**
|
||||
- WebSocket connects locally but fails in production (public URL)
|
||||
- SSE stream closes after 60 seconds with no activity
|
||||
- Real-time updates work on Android (same network) but not iPhone (over tunnel)
|
||||
|
||||
**Phase to address:**
|
||||
Infrastructure spike phase, before real-time list sync is implemented.
|
||||
|
||||
---
|
||||
|
||||
## Technical Debt Patterns
|
||||
|
||||
| Shortcut | Immediate Benefit | Long-term Cost | When Acceptable |
|
||||
|----------|-------------------|----------------|-----------------|
|
||||
| Full PROPFIND poll on every sync interval instead of WebDAV-Sync | Simpler code | Bandwidth waste; shows up immediately at any real-world poll frequency | Never — implement sync-token from day one |
|
||||
| Client-side RRULE expansion instead of server CALDAV:expand | Avoids REPORT query complexity | DST and override bugs that are very hard to diagnose | Never for authoritative display; OK for UI-only preview |
|
||||
| Storing all-day events as DATETIME in MariaDB | Avoids DATE type handling | Timezone shift bugs on display | Never |
|
||||
| Skip `If-Match` ETag on write-back | Simpler write path | Silent data loss on concurrent edits | Acceptable for v1 only if single-writer constraint is documented; still risky |
|
||||
| iframe silent token renewal instead of refresh token rotation | Less backend work | Breaks on iOS Safari ITP; causes random logouts | Never for this stack |
|
||||
| Cache-First strategy for API responses | Fast perceived performance | Stale calendar/list data shown as current | Never for data routes |
|
||||
| Notification payload requiring server fetch before display | Richer notifications | Violates `userVisibleOnly`; kills iOS subscription after 3 events | Never |
|
||||
| Single-instance recurring event override deferred | Significantly simpler write logic | Users frustrated when editing "just this one" is impossible | Acceptable for v1 — document clearly |
|
||||
|
||||
---
|
||||
|
||||
## Integration Gotchas
|
||||
|
||||
| Integration | Common Mistake | Correct Approach |
|
||||
|-------------|----------------|------------------|
|
||||
| Fastmail CalDAV | Using bare `caldav.fastmail.com` as base URL | Use `https://caldav.fastmail.com/dav/principals/user/{email}/` |
|
||||
| Fastmail CalDAV | Assuming JMAP supports calendars | CalDAV only; JMAP calendar spec not yet production at Fastmail |
|
||||
| Fastmail CalDAV | Assuming broker token sees all members' personal calendars | Explicit share+accept required per calendar per account |
|
||||
| CalDAV ETag | Assuming ETag is always returned after PUT | Sometimes absent; always re-fetch after write |
|
||||
| CalDAV sync-token | Not handling 403 on expired token | Fall back to full PROPFIND resync when token is rejected |
|
||||
| CalDAV recurring events | Writing RECURRENCE-ID override as a new resource | Must be an additional VEVENT in the same VCALENDAR resource |
|
||||
| iOS Web Push | Calling `requestPermission()` outside a click handler | Permission silently denied; must be in direct user gesture handler |
|
||||
| iOS Web Push | Not wrapping `showNotification()` in `event.waitUntil()` | 3 silent pushes = permanent subscription revocation |
|
||||
| iOS Web Push | Not handling 410 Gone from push service | Dead subscriptions accumulate; subscription table grows without cleanup |
|
||||
| Authelia OIDC v4.39+ | Reading `groups` from ID token | Fetch from userinfo endpoint or skip groups entirely |
|
||||
| Authelia OIDC | iframe silent renewal with ITP | Use refresh token rotation via confidential backend client |
|
||||
| Pangolin tunnel | Assuming HTTP proxy config handles WebSocket | Requires explicit WS upgrade passthrough and timeout config |
|
||||
|
||||
---
|
||||
|
||||
## Performance Traps
|
||||
|
||||
| Trap | Symptoms | Prevention | When It Breaks |
|
||||
|------|----------|------------|----------------|
|
||||
| Full calendar PROPFIND on every poll | High Fastmail API usage; slow sync | Use WebDAV-Sync (sync-token) for delta fetches | From first deployment |
|
||||
| Expanding RRULE in-process for a 2-year window | CPU spike on backend; slow calendar load | Limit expansion to visible time window via CALDAV:expand | Any calendar with >30 recurring events |
|
||||
| Fetching all VCALENDAR objects to find changed ones | N+1 CalDAV requests per sync | Use PROPFIND to get ETags first; only GET objects with changed ETags | Immediately on any non-trivial calendar |
|
||||
| WebSocket broadcast to all clients on every list mutation | Unnecessary events to clients not viewing that list | Filter broadcasts by list membership/subscription | Once any second device is connected |
|
||||
|
||||
---
|
||||
|
||||
## Security Mistakes
|
||||
|
||||
| Mistake | Risk | Prevention |
|
||||
|---------|------|------------|
|
||||
| Storing Fastmail API token in client-accessible storage | Token leak → full Fastmail account calendar access | Token lives only in backend env vars; never exposed to frontend |
|
||||
| Forwarding raw VCALENDAR to the frontend | iCalendar payloads can contain injected properties | Parse and reconstruct a safe JSON event object server-side |
|
||||
| Not validating OIDC `aud` claim | Any Authelia client can impersonate FamilySync users | Verify `aud` matches your registered client ID on every token validation |
|
||||
| Push subscription endpoint stored unencrypted with PII | Subscription URLs are tied to device identity | Store encrypted; treat as sensitive PII; delete on logout |
|
||||
| Trusting `display-mode: standalone` for access control | Cannot be relied on for security | Standalone check is UX-only; all access control at API layer via token |
|
||||
|
||||
---
|
||||
|
||||
## UX Pitfalls
|
||||
|
||||
| Pitfall | User Impact | Better Approach |
|
||||
|---------|-------------|-----------------|
|
||||
| No iOS install guide in the app | Wife never installs PWA; never gets push notifications | Show install CTA with Safari-specific screenshots on first browser visit |
|
||||
| Push permission requested on first page load | iOS silently denies; permission cannot be re-requested | Request permission only after explaining why, inside a tapped button |
|
||||
| "Notification" as the only feedback for list changes | Missed if phone silent; no in-app indicator | Show badge/dot on list items changed since last viewed; push is secondary |
|
||||
| Calendar event edit with no conflict warning | Overwrites event changed in native Fastmail app | Show "this event was modified elsewhere" warning on 412; offer overwrite or reload |
|
||||
| No offline message | App appears broken when offline | Show "offline — viewing cached data" banner; list edits queue for sync |
|
||||
| Recurring event edit options not explained | User edits "this event" not knowing it changes all future events | For v1, edit-all only with clear label "This changes all future events" |
|
||||
|
||||
---
|
||||
|
||||
## "Looks Done But Isn't" Checklist
|
||||
|
||||
- [ ] **Calendar sync:** Often missing ETag/sync-token handling — verify the poller uses delta sync, not full PROPFIND, on the second and subsequent runs
|
||||
- [ ] **All-day events:** Often missing allDay flag — verify a June 5 all-day event appears on June 5 in UTC-5 timezone, not June 4
|
||||
- [ ] **Recurring events:** Often missing DST boundary test — verify a weekly recurring event set in July still shows at the correct hour in November
|
||||
- [ ] **iOS push:** Often missing subscription health-check — verify a fresh subscription is created after ITP clears the service worker (simulate by clearing site data in Safari settings)
|
||||
- [ ] **iOS push:** Often missing `event.waitUntil()` — verify in Safari DevTools that push events show as resolved, not terminated
|
||||
- [ ] **Personal calendar sharing:** Often assumed to be automatic — verify broker token PROPFIND actually returns the wife's personal calendar URL
|
||||
- [ ] **Write-back:** Often missing 412 handling — verify the app does not silently drop an edit when the event was modified concurrently in the native app
|
||||
- [ ] **Service worker update:** Often ships with default asset caching headers — verify `sw.js` is served with `Cache-Control: no-cache`
|
||||
- [ ] **Auth token renewal:** Often untested at expiry — verify the app does not redirect to login when the access token expires mid-session
|
||||
- [ ] **Pangolin WebSocket:** Often only tested on LAN — verify WebSocket or SSE works end-to-end over the Pangolin public URL before shipping real-time sync
|
||||
|
||||
---
|
||||
|
||||
## Recovery Strategies
|
||||
|
||||
| Pitfall | Recovery Cost | Recovery Steps |
|
||||
|---------|---------------|----------------|
|
||||
| JMAP calendar assumption baked in | HIGH | Rewrite broker protocol layer; API contract may change |
|
||||
| All-day event stored as DATETIME | HIGH | Migration required; all calendar cache invalid; must resync |
|
||||
| No ETag/sync-token on poller | MEDIUM | Swap polling logic; no data loss, but adds a sprint |
|
||||
| Push subscription table full of dead iOS endpoints | LOW | Run cleanup job: send to each endpoint, delete 410/404 responses |
|
||||
| Personal calendars not shared to broker | LOW | Manual Fastmail share + accept; no code change |
|
||||
| VTIMEZONE DST bug in emitted iCalendar | MEDIUM | Swap tz library; clear Fastmail events and re-sync |
|
||||
| Service worker cache serving stale data | LOW | Add route-specific Workbox config; redeploy |
|
||||
| Authelia groups claim removed after upgrade | LOW | Update claim source to userinfo endpoint; or remove group checks |
|
||||
| Pangolin WebSocket drops | MEDIUM | Switch real-time transport from WS to SSE; no data model change |
|
||||
|
||||
---
|
||||
|
||||
## Pitfall-to-Phase Mapping
|
||||
|
||||
| Pitfall | Prevention Phase | Verification |
|
||||
|---------|------------------|--------------|
|
||||
| JMAP calendar not available | Calendar broker spike (Phase 1) | Confirm CalDAV endpoint returns events before any further work |
|
||||
| RRULE client-side expansion | Calendar fetch design (Phase 1) | REPORT with CALDAV:expand in the first integration test |
|
||||
| All-day event DATE vs DATETIME | Data model design (Phase 1) | Schema review; allDay field present; timezone test |
|
||||
| ETag / sync-token incremental sync | Calendar sync engine (Phase 1-2) | Second poll must use sync-token; verify in network logs |
|
||||
| Write-back RECURRENCE-ID corruption | Event edit UI (Phase 2-3) | Integration test: edit instance, verify series unchanged |
|
||||
| DST timezone shift | Calendar write phase (Phase 2) | Dedicated DST fixture test before milestone sign-off |
|
||||
| Personal calendar sharing | Infrastructure/deployment (Phase 1) | Manual proof-of-concept share before unified view is built |
|
||||
| iOS install guide missing | PWA onboarding (Phase 2) | Test with non-technical user; do not consider done until wife installs |
|
||||
| iOS push subscription killed (silent push) | Push implementation (Phase 2-3) | Verify `event.waitUntil()` pattern; test 3 consecutive pushes |
|
||||
| Declarative Web Push payload design | Push design (Phase 2) | Payload schema review before server-side push sender is built |
|
||||
| EU DMA restriction | Risk assessment (Phase 1) | One-time check of Apple ID regions; document conclusion |
|
||||
| Service worker stale cache (data routes) | PWA SW config (Phase 2) | Network tab audit: API calls must not be served from cache |
|
||||
| SW update staleness | PWA build config (Phase 2) | Verify `sw.js` has `Cache-Control: no-cache` in Nginx config |
|
||||
| Calendar cache double-write | Cache design (Phase 1-2) | Write-through invalidation test: write → immediate re-fetch from Fastmail |
|
||||
| List sync reconnect gap | Lists implementation (Phase 2) | Disconnect mid-edit test; verify item appears after reconnect |
|
||||
| Authelia groups claim breaking change | Auth integration (Phase 1) | Decode ID token; confirm no groups dependency; document decision |
|
||||
| Authelia SPA silent renewal | Auth integration (Phase 1) | Test token expiry behavior before frontend is considered done |
|
||||
| Pangolin WebSocket passthrough | Infrastructure spike (Phase 1) | WebSocket smoke test over public URL before real-time feature is built |
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
- [Fastmail API Documentation](https://www.fastmail.com/dev/) — confirms CalDAV only for calendars; JMAP calendar pending
|
||||
- [Fastmail CalDAV URL discovery](https://utf9k.net/blog/fastmail-caldav/) — principal URL format requirement
|
||||
- [Fastmail shared calendar improvements](https://www.fastmail.com/blog/shared-calendar-improvements/) — ACL sharing model, secretary mode
|
||||
- [Sabre/DAV: Building a CalDAV client](https://sabre.io/dav/building-a-caldav-client/) — ETag, sync-token, UID, VTIMEZONE pitfalls
|
||||
- [RFC 5545 RRULE](https://icalendar.org/iCalendar-RFC-5545/3-8-5-3-recurrence-rule.html) — recurrence set construction
|
||||
- [RFC 5545 EXDATE](https://icalendar.org/iCalendar-RFC-5545/3-8-5-1-exception-date-times.html) — exception date handling
|
||||
- [CalDAV ETag not updating after event edit — Google Issue Tracker](https://issuetracker.google.com/issues/153145152) — real-world ETag inconsistency
|
||||
- [Home Assistant: CalDAV all-day events UTC issue](https://github.com/home-assistant/core/issues/25814) — DATE vs DATETIME bug documented in production
|
||||
- [Nextcloud: recurring event single move duplicates event](https://github.com/owncloud/core/issues/24591) — RECURRENCE-ID write-back failure
|
||||
- [DST CalDAV shift bugs (khal)](https://github.com/pimutils/khal/issues/755) — VTIMEZONE DST implementation errors
|
||||
- [PWA Push Notifications on iOS 2026 (WebsCraft)](https://webscraft.org/blog/pwa-pushspovischennya-na-ios-u-2026-scho-realno-pratsyuye?lang=en) — current iOS push state
|
||||
- [MagicBell: PWA iOS Limitations 2026](https://www.magicbell.com/blog/pwa-ios-limitations-safari-support-complete-guide) — iOS 16.4+ requirement, EU DMA
|
||||
- [iOS push subscriptions terminated after 3 notifications (DEV Community)](https://dev.to/progressier/how-to-fix-ios-push-subscriptions-being-terminated-after-3-notifications-39a7) — `event.waitUntil()` requirement
|
||||
- [iOS Web Push device unregisters spontaneously (Firebase SDK issue)](https://github.com/firebase/firebase-js-sdk/issues/8010) — ITP-driven subscription loss
|
||||
- [WebKit: Meet Declarative Web Push](https://webkit.org/blog/16535/meet-declarative-web-push/) — new WebKit push model, ITP resilience
|
||||
- [Apple Developer Forums: Web Push on iOS](https://developer.apple.com/forums/thread/732594) — subscription expiry, `pushsubscriptionchange` not supported
|
||||
- [Authelia OIDC Clients Configuration](https://www.authelia.com/configuration/identity-providers/openid-connect/clients/) — redirect URI, PKCE, groups claim
|
||||
- [Authelia OIDC FAQ](https://www.authelia.com/integration/openid-connect/frequently-asked-questions/) — breaking changes including groups in ID token
|
||||
- [OIDC SPA: Third-party cookies and session restoration](https://docs.oidc-spa.dev/resources/third-party-cookies-and-session-restoration) — iframe silent renewal + ITP
|
||||
- [Pangolin WebSocket issue #1034](https://github.com/fosrl/pangolin/issues/1034) — WebSocket upgrade failure through tunnel
|
||||
- [WebSocket reconnection guide (WebSocket.org)](https://websocket.org/guides/reconnection/) — state sync on reconnect, missed event gap
|
||||
|
||||
---
|
||||
*Pitfalls research for: Fastmail-brokered family calendar + shared-list PWA with Web Push, self-hosted behind Authelia*
|
||||
*Researched: 2026-06-03*
|
||||
@@ -0,0 +1,268 @@
|
||||
# Stack Research
|
||||
|
||||
**Domain:** Self-hosted family calendar + shared-lists PWA on Fastmail
|
||||
**Researched:** 2026-06-03
|
||||
**Confidence:** MEDIUM-HIGH (calendar sharing cross-account caveat: LOW; rest HIGH)
|
||||
|
||||
---
|
||||
|
||||
## 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 |
|
||||
| ioredis | 5.11.0 | Redis client | Pub/sub for broadcasting list-change events to SSE connections across Node processes |
|
||||
| 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
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
npm install hono @hono/node-server @hono/oidc-auth @hono/zod-validator
|
||||
npm install drizzle-orm mysql2 ioredis
|
||||
npm install tsdav ical.js rrule
|
||||
npm install web-push zod openid-client
|
||||
|
||||
# Frontend
|
||||
npm install react react-dom @tanstack/react-query zustand
|
||||
npm install -D vite vite-plugin-pwa
|
||||
|
||||
# Dev
|
||||
npm install -D typescript drizzle-kit vitest @types/node @types/web-push
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Calendar Integration: CalDAV, Not JMAP
|
||||
|
||||
**Decision: CalDAV via `tsdav`. JMAP for calendars is not available from Fastmail.**
|
||||
|
||||
Fastmail's developer docs (as of 2026-06-03) state explicitly: calendar access is CalDAV only; JMAP calendar support is planned but blocked on RFC 8984 specification finalization. The JMAP working group has not finalized the calendars spec. Do not plan around JMAP calendars — it is not a near-term option.
|
||||
|
||||
**CalDAV mechanics with tsdav:**
|
||||
|
||||
- Principal URL: `https://caldav.fastmail.com/dav/principals/user/broker@fastmail.com/`
|
||||
- `tsdav` performs `PROPFIND` on the principal to discover all calendar collections, then fetches each collection's events via `REPORT` (calendar-query or calendar-multiget).
|
||||
- One app password covers all calendars owned by that account under the default "Mail, Contacts & Calendars" scope.
|
||||
- `tsdav` returns raw iCalendar strings. Pass each to `ical.js` for parsing into event objects, then use `rrule` for RRULE expansion into the date range the UI needs.
|
||||
- Write-back (create/edit/delete): PUT a new `.ics` to the collection URL; DELETE by UID.
|
||||
|
||||
**Authentication model:**
|
||||
|
||||
Use a Fastmail app password (not OAuth) for the backend broker token. App passwords are simple HTTP Basic credentials. OAuth is intended for distributing apps to Fastmail users — not applicable here. The app password is a server secret stored in an environment variable; it never leaves the backend.
|
||||
|
||||
**Personal calendar aggregation — IMPORTANT CAVEAT (LOW confidence):**
|
||||
|
||||
Fastmail's multi-user calendar sharing is documented only for users within the same Fastmail account (i.e., a multi-user/family Fastmail subscription). If both household members are on the same Fastmail family plan, the primary account holder can be granted edit access to the other member's personal calendar, and the broker token for the primary account will discover and read/write those shared calendars via CalDAV. If the wife has a separate independent Fastmail account, cross-account CalDAV sharing via a single broker token is unconfirmed — this should be tested before Phase 1 commits to the personal-calendar overlay feature. The shared family calendar (owned by the primary account) works unconditionally.
|
||||
|
||||
**What NOT to use for calendars:**
|
||||
|
||||
- `node-ical`: older fork with weaker RRULE support; ical.js is maintained by Mozilla and is the reference implementation
|
||||
- Direct `fetch`/`axios` against 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
|
||||
|
||||
**Decision: Hono on Node.js**
|
||||
|
||||
Hono is the right size for this app. Express is fine but has no TypeScript-native ergonomics. NestJS is overkill for a two-person household app. Hono gives you:
|
||||
- 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`
|
||||
|
||||
**ORM: Drizzle + mysql2**
|
||||
|
||||
Drizzle is the correct choice over Prisma for this stack:
|
||||
- Prisma generates a binary engine that adds complexity in Docker images and has weaker MariaDB compatibility signals
|
||||
- Drizzle uses `mysql2` directly — the same driver you'd use raw; no runtime translation layer
|
||||
- Drizzle's `mysqlTable` schema is fully MariaDB-compatible (MariaDB is wire-compatible with MySQL; Drizzle's `mysql` dialect works)
|
||||
- Type inference from schema → query results is the core value proposition; zero runtime overhead
|
||||
|
||||
---
|
||||
|
||||
## React PWA Stack
|
||||
|
||||
**Build: Vite 8 + vite-plugin-pwa 1.3.0**
|
||||
|
||||
Standard for React PWAs. `vite-plugin-pwa` configures the Web App Manifest and injects a Workbox service worker. Use `injectManifest` strategy (not `generateSW`) so you have explicit control over the service worker file — required for Web Push subscription management.
|
||||
|
||||
**State/data: TanStack Query + Zustand**
|
||||
|
||||
- TanStack Query owns all server-side state: calendar events, lists, user profile. It handles background refetch, cache invalidation, and loading states. Use `queryClient.invalidateQueries` from 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.
|
||||
|
||||
**Web Push (VAPID):**
|
||||
|
||||
1. Generate a VAPID key pair once (`web-push generateVAPIDKeys`), store in environment variables.
|
||||
2. Expose the public key via an API route; the PWA calls `pushManager.subscribe({ userVisibleOnly: true, applicationServerKey })` from a user-gesture handler.
|
||||
3. Store the `PushSubscription` object (endpoint, keys) in MariaDB per user.
|
||||
4. Backend sends notifications via `web-push.sendNotification(subscription, payload)`.
|
||||
|
||||
**iOS-specific Web Push constraints (CRITICAL):**
|
||||
|
||||
| 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` |
|
||||
|
||||
**Declarative Web Push (Safari 18.4+):** Apple shipped Declarative Web Push in Safari 18.4 (iOS 18.4, March 2025). It's backward-compatible: send a JSON payload with `"web_push": 8030` and the browser renders the notification without a service worker handler. The `web-push` npm library (v3.6.7) does not generate this format natively — you'd hand-craft the JSON payload for iOS while the same endpoint handles standard Web Push for Android/desktop. As of mid-2026, Declarative Web Push is a W3C Working Draft and the preferred format for iOS/macOS push. Build the push payload to be Declarative Web Push compatible from day one (it's just a JSON schema change), since `web-push` still handles the VAPID transport layer.
|
||||
|
||||
**Onboarding UX for iOS (wife):** The install-to-home-screen step is unavoidable for push notifications. Design the first-run flow to prompt this explicitly (custom install banner, step-by-step guide). Once installed, OIDC login via Authelia is one tap — the low-friction goal is achievable.
|
||||
|
||||
---
|
||||
|
||||
## Authelia OIDC Integration
|
||||
|
||||
**Decision: `@hono/oidc-auth` middleware**
|
||||
|
||||
Authelia exposes a standards-compliant OIDC discovery endpoint. `@hono/oidc-auth` uses `oauth4webapi` under the hood, supports authorization code + PKCE, and produces storage-less JWT session cookies — no Redis or session DB required for auth state.
|
||||
|
||||
**Flow:**
|
||||
1. Unauthenticated request → middleware redirects to Authelia's authorization endpoint
|
||||
2. Authelia authenticates the user, redirects back with `code`
|
||||
3. Middleware exchanges code for tokens, creates signed JWT session cookie (httpOnly, Secure, SameSite=Lax)
|
||||
4. Cookie is verified on every request; refresh tokens are used to silently re-authenticate before expiry
|
||||
|
||||
**Authelia configuration requirements:**
|
||||
- `response_types: [code]`
|
||||
- `grant_types: [authorization_code, refresh_token]`
|
||||
- `require_pkce: true`, `pkce_challenge_method: S256`
|
||||
- `token_endpoint_auth_method: client_secret_basic`
|
||||
|
||||
Authelia's own integration docs show this exact pattern for Express.js (`express-openid-connect`). `@hono/oidc-auth` is the Hono-native equivalent. If it hits edge cases, `openid-client` v6 is the lower-level fallback.
|
||||
|
||||
**Do NOT use:** `oidc-client-ts` — it is a browser-side library for SPAs doing the OIDC flow in the frontend. This app has a backend session; the OIDC flow belongs on the server.
|
||||
|
||||
---
|
||||
|
||||
## Live List Sync
|
||||
|
||||
**Decision: SSE (Server-Sent Events) + Redis Pub/Sub, not WebSockets**
|
||||
|
||||
Lists are co-edited by two people. The update direction is server → client (server broadcasts when one client mutates a list). SSE is simpler than WebSockets for this: plain HTTP, works through proxies, automatic reconnection in browsers.
|
||||
|
||||
Pattern:
|
||||
1. Client opens `GET /api/lists/stream` → Hono `streamSSE` keeps connection alive
|
||||
2. On a list mutation, the backend publishes a `list:updated:{listId}` event to Redis
|
||||
3. All Node processes subscribed to Redis receive the event and push it to connected SSE clients
|
||||
4. React Query on the client receives the SSE event → `invalidateQueries(['lists', listId])` → refetches
|
||||
|
||||
Redis (`ioredis`) is only needed if multiple Node containers run behind a load balancer. For a single Unraid Docker Compose with one backend container, you can skip Redis and use an in-process event emitter — leave the abstraction clean so Redis can be added later.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
1. **Personal calendar cross-account sharing (LOW confidence):** Does the wife's personal Fastmail calendar (if she has a separate account) appear in the broker token's CalDAV principal discovery? This must be manually tested before committing to the personal-calendar overlay in Phase 1. If it does not work, the v1 fallback is: shared family calendar only, with a read-only ICS subscription URL for the wife's personal calendar displayed separately.
|
||||
|
||||
2. **Declarative Web Push server-side format:** The `web-push` npm library does not natively output the `"web_push": 8030` Declarative Web Push JSON format. Verify whether iOS 18.4+ APNs endpoint accepts standard VAPID push payloads (it does for the VAPID transport layer) vs. needing the declarative JSON in the payload body. The answer is: VAPID is the transport; Declarative Web Push is the payload format. Both can coexist in the same push subscription.
|
||||
|
||||
3. **EU DMA regression:** If either household member is in the EU on iOS 17.4+, PWA standalone mode is broken and push will not work. Confirm geographic context is outside EU — this is the project owner's constraint to verify.
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
- [Fastmail API Documentation](https://www.fastmail.com/dev/) — Confirmed CalDAV-only for calendars; JMAP calendars not available
|
||||
- [Fastmail App Passwords](https://www.fastmail.help/hc/en-us/articles/360058752854-App-passwords) — Scope covers CalDAV; single password covers all calendars in account
|
||||
- [Using Fastmail with CalDAV libraries](https://utf9k.net/blog/fastmail-caldav/) — Principal URL pattern, app password auth
|
||||
- [Fastmail Calendar Sharing](https://www.fastmail.help/hc/en-us/articles/1500000279781-Sharing-calendars-with-other-users) — Sharing is multi-user-account scoped; cross-account sharing unconfirmed
|
||||
- [tsdav npm](https://www.npmjs.com/package/tsdav) — Version 2.2.2 confirmed
|
||||
- [ical.js npm](https://www.npmjs.com/package/ical.js) — Version 2.2.1 confirmed; Mozilla-maintained
|
||||
- [rrule npm](https://www.npmjs.com/package/rrule) — Version 2.8.1 confirmed
|
||||
- [Hono](https://hono.dev/) — Version 4.12.23; Node.js adapter confirmed
|
||||
- [Drizzle ORM MySQL](https://orm.drizzle.team/docs/get-started-mysql) — MariaDB via mysql2 confirmed
|
||||
- [vite-plugin-pwa](https://vite-pwa-org.netlify.app/) — Version 1.3.0; Workbox 7 integration
|
||||
- [web-push npm](https://www.npmjs.com/package/web-push) — Version 3.6.7
|
||||
- [Meet Declarative Web Push — WebKit](https://webkit.org/blog/16535/meet-declarative-web-push/) — Safari 18.4+, iOS 18.4+ confirmed
|
||||
- [PWA iOS Limitations 2026](https://www.magicbell.com/blog/pwa-ios-limitations-safari-support-complete-guide) — iOS 16.4 minimum; home screen required; EU DMA regression
|
||||
- [Authelia Express.js Integration](https://www.authelia.com/integration/openid-connect/clients/expressjs/) — Authorization code + PKCE flow; client_secret_basic
|
||||
- [@hono/oidc-auth GitHub](https://github.com/honojs/middleware/tree/main/packages/oidc-auth) — Storage-less JWT session cookies; Version 1.8.3
|
||||
|
||||
---
|
||||
|
||||
*Stack research for: FamilySync — self-hosted family calendar + shared-lists PWA on Fastmail*
|
||||
*Researched: 2026-06-03*
|
||||
@@ -0,0 +1,278 @@
|
||||
# Project Research Summary
|
||||
|
||||
**Project:** FamilySync
|
||||
**Domain:** Self-hosted family calendar + shared-lists PWA (Fastmail-brokered, Authelia-authed, Unraid-hosted)
|
||||
**Researched:** 2026-06-03
|
||||
**Confidence:** MEDIUM-HIGH overall; LOW on personal-calendar CalDAV ACL behavior (the critical unresolved risk)
|
||||
|
||||
## Executive Summary
|
||||
|
||||
FamilySync is a purpose-built two-person household coordination hub: a React PWA that aggregates Fastmail-hosted calendars (shared family + each member's personal) into a unified color-coded view, adds shared collaborative lists with live co-edit sync, and delivers Web Push alerts — all behind Authelia OIDC with zero app-store friction. The project has resolved its JMAP-vs-CalDAV open question: Fastmail does not expose calendars over JMAP. CalDAV via `tsdav` is the only available protocol and the one to build against exclusively. The broker-cache architecture (background ctag polling + MariaDB cache) is mandatory because Fastmail provides no push webhook; all calendar reads hit the local cache, not Fastmail on-demand.
|
||||
|
||||
The single biggest v1 risk is whether the broker's single app password can discover and read the wife's personal Fastmail calendar after it has been shared via Fastmail's in-app CalDAV ACL flow. Fastmail's documentation confirms the sharing feature exists and follows CalDAV draft ACL standards, but does not explicitly state whether shared calendars appear in the sharing account's principal discovery. This must be spiked and confirmed in Phase 1, before any work is committed to the personal-calendar overlay feature. The fallback is per-member app passwords (at most two credentials for this household). A deeper risk: if the wife's personal calendar lives on iCloud rather than Fastmail, the broker cannot read it at all — this must be confirmed as part of the same spike. Canada is unaffected by the EU DMA PWA restriction; iOS push applies to both household members without the DMA caveat.
|
||||
|
||||
iOS Web Push has multiple silent failure modes that are not obvious until production: the PWA must be installed to Home Screen (no browser-tab push on iOS), `showNotification()` must be wrapped in `event.waitUntil()` or iOS permanently revokes the subscription after three silent pushes, and Apple's ITP can silently clear service worker registrations. Recurring events are the highest-complexity table-stakes feature — use CalDAV server-side `CALDAV:expand` in REPORT requests to get pre-expanded instances rather than relying solely on client-side RRULE expansion, and defer single-instance RECURRENCE-ID edits to v1.x. The recommended stack has strong convergence across all four research threads: Hono + Drizzle (mysql2/MariaDB) + tsdav + ical.js + rrule + vite-plugin-pwa + web-push + @hono/oidc-auth, with SSE (preferred over WebSockets for proxy resilience through Pangolin) + optional Redis for list sync.
|
||||
|
||||
## Key Findings
|
||||
|
||||
### Recommended Stack
|
||||
|
||||
The stack is tightly constrained by the existing infra (MariaDB, no PostgreSQL; Unraid Docker Compose; Authelia OIDC; Pangolin tunnel) and the Fastmail CalDAV protocol decision. All four research files converged on the same library choices with no meaningful disagreement.
|
||||
|
||||
**Core technologies:**
|
||||
- **Hono 4.x + @hono/node-server** — HTTP framework; Web Standards-native, first-class TypeScript, built-in `streamSSE` helper for SSE, WebSocket via node adapter. Lighter than Express; better TypeScript than Fastify for this size.
|
||||
- **Drizzle ORM 0.45.x + mysql2 3.x** — Type-safe MariaDB access; no binary engine (unlike Prisma); wire-compatible with MariaDB via `mysql` dialect; `drizzle-kit` for migrations.
|
||||
- **tsdav 2.2.2** — The only maintained TypeScript CalDAV client; handles PROPFIND, REPORT, PUT, DELETE against Fastmail's `caldav.fastmail.com` endpoint.
|
||||
- **ical.js 2.2.1** — Mozilla-maintained iCalendar parser; parse raw VCALENDAR/VEVENT payloads returned by tsdav; handles VTIMEZONE, RDATE, EXDATE.
|
||||
- **rrule 2.8.1** — RRULE string expansion for UI display layer (calendar window ±1 month); use only after confirming against server-side CALDAV:expand for authoritative instances.
|
||||
- **vite-plugin-pwa 1.3.0 (injectManifest strategy)** — Workbox integration for service worker; use `injectManifest` not `generateSW` for explicit SW control required by Web Push subscription management.
|
||||
- **web-push 3.6.7** — VAPID push sender; handles APNs Web Push (iOS) and FCM (Android); design payloads as self-contained Declarative Web Push JSON from day one (ITP-resilient on iOS 18.4+).
|
||||
- **@hono/oidc-auth 1.8.3** — Storage-less JWT session cookies; authorization-code + PKCE against Authelia's OIDC endpoint. Do NOT use `oidc-client-ts` (browser-side SPA library; wrong layer).
|
||||
- **React 19 + TanStack Query 5 + Zustand 5** — TanStack Query owns all server state; Zustand owns pure UI state (selected date, color assignments, drawer state).
|
||||
- **SSE via Hono `streamSSE`** — Preferred over WebSockets for list sync; unidirectional server-to-client, more proxy-resilient through Pangolin. Verify Pangolin WS pass-through in an infra spike if WS is chosen instead.
|
||||
- **ioredis 5.x** — Redis pub/sub for broadcasting list-change events; optional for single-container deployment (in-process EventEmitter works for one Node process).
|
||||
|
||||
**Critical decisions locked:**
|
||||
- CalDAV only, not JMAP — no reconsideration
|
||||
- Principal URL: `https://caldav.fastmail.com/dav/principals/user/{email}/` — bare root is insufficient
|
||||
- IANA timezone IDs only (e.g., `America/Toronto`) — never Windows-style IDs in emitted VTIMEZONE
|
||||
- Serve `sw.js` with `Cache-Control: no-cache` — Safari respects HTTP cache for SW scripts
|
||||
|
||||
### Expected Features
|
||||
|
||||
**Must have (table stakes — v1):**
|
||||
- Unified multi-calendar view with per-member color coding (shared family + personal calendars)
|
||||
- Day / week / month / agenda views
|
||||
- Create / edit / delete events with CalDAV write-back (all-day and timed)
|
||||
- Recurring events: display and create (via CALDAV:expand); single-instance edit deferred to v1.x
|
||||
- Event push notifications (reminders) and list-change notifications
|
||||
- Shared lists: create named lists, add/check/reorder/delete items
|
||||
- Live list co-edit sync (SSE or WebSocket)
|
||||
- OIDC login via Authelia — must feel seamless for non-technical user
|
||||
- PWA installability (Add to Home Screen) with guided iOS install UX — load-bearing, not optional
|
||||
|
||||
**Should have (differentiators — v1):**
|
||||
- Personal calendar overlay (shared + each member's personal in one view) — THE differentiator; blocked on CalDAV ACL spike
|
||||
- Event change push notifications ("partner changed an event") — documented Google Family Calendar gap
|
||||
- Optimistic list check-off with instant feedback
|
||||
|
||||
**Defer (v2+):**
|
||||
- Wall-display / kiosk dashboard — explicitly out of scope per PROJECT.md
|
||||
- Single-instance recurring event edit (RECURRENCE-ID) — high complexity, low v1 priority; ship "edit all instances" only with clear label
|
||||
- "This and following" recurring edit
|
||||
- Timezone display toggle
|
||||
- Meal planning, chore system, AI import, RSVP, event comments — confirmed anti-features for this household
|
||||
|
||||
### Architecture Approach
|
||||
|
||||
The architecture is a single Docker Compose stack on Unraid: one Node/Hono API container (broker + list + push + scheduler in one process), MariaDB, and optional Redis. The broker pattern is mandatory: all calendar reads hit the MariaDB cache; a background ctag poller (5-min interval) issues lightweight PROPFIND to detect changes and REPORT to sync. No Fastmail push webhook exists. The PWA serves as the sole frontend; Authelia handles all authentication upstream via Pangolin tunnel. Pangolin/Newt WebSocket pass-through must be verified in an infrastructure spike before real-time sync is built.
|
||||
|
||||
**Major components:**
|
||||
1. **CalDAV Broker** (`broker/`) — tsdav wrapper, ctag poller, REPORT sync, RRULE expand, MariaDB cache write. All Fastmail I/O isolated here; nothing else imports from this module.
|
||||
2. **App API** (Hono) — OIDC session middleware, REST routes for calendars/events/lists, SSE hub, push subscription routes.
|
||||
3. **Reminder Scheduler** — In-process node-cron; queries events within 15-min window, fires VAPID push.
|
||||
4. **Lists Domain** — REST CRUD + Redis pub/sub emit on every write; SSE hub delivers to connected clients.
|
||||
5. **React PWA** — Calendar views (rrule.js for display expansion), list co-edit UI, push subscription registration, guided iOS install flow.
|
||||
6. **MariaDB** — Persistent store: users, calendar event cache (raw VEVENT blob + dtstart_utc + ctag), lists/items, push_subscriptions.
|
||||
|
||||
**Key patterns:**
|
||||
- Broker cache + ctag polling — never proxy calendar reads to Fastmail on-demand
|
||||
- Store raw VEVENT blob + `dtstart_utc` column (DATE type for all-day) — never pre-expand RRULE into rows
|
||||
- Write-through cache invalidation — after CalDAV PUT, re-fetch server's version; never cache the client's version
|
||||
- Use `oidc_iss + oidc_sub` as stable identity composite key — never `email`
|
||||
- All-day events: `{ date: "YYYY-MM-DD", allDay: true }` throughout the stack — never coerce to JS Date or SQL DATETIME
|
||||
|
||||
### Critical Pitfalls
|
||||
|
||||
1. **Personal calendar CalDAV ACL (LOW confidence — must spike in Phase 1)** — Broker token may not discover the wife's personal calendar after sharing unless Fastmail's share+accept flow causes it to appear in principal discovery. Additionally, the wife's personal calendar may be on iCloud, making broker access impossible. Spike before committing to the overlay feature. Fallback: per-member app passwords (two credentials max).
|
||||
|
||||
2. **All-day events stored as DATETIME** — `DATE` (not `DATETIME`) in iCalendar; storing as UTC shifts events to the previous day in negative-offset timezones. Store as `DATE` column in MariaDB; emit ISO date string without time component; detect `allDay: true` in the frontend. This bug has hit Home Assistant's CalDAV integration in production.
|
||||
|
||||
3. **iOS push subscription silent revocation** — iOS permanently revokes push subscriptions after 3 silent pushes (push event received but no visible notification). Always wrap `showNotification()` in `event.waitUntil()`. Payloads must be self-contained (no fetch-before-display). Implement subscription health-check on every app open. Handle 410 Gone immediately.
|
||||
|
||||
4. **Recurring event RECURRENCE-ID write corruption** — Editing a single occurrence requires injecting a new VEVENT block with RECURRENCE-ID into the same VCALENDAR resource. For v1, restrict to "edit all instances" only with a clear label; defer single-instance override to v1.x.
|
||||
|
||||
5. **Pangolin WebSocket pass-through** — Known issue (#1034) where WebSocket connections fail through Pangolin even when HTTP works. Verify SSE or WS pass-through in an infra spike before building real-time list sync. SSE is more proxy-resilient and is the preferred transport.
|
||||
|
||||
6. **ETag / sync-token incremental sync** — Use WebDAV-Sync (sync-token) for delta fetches from day one; fall back to ctag polling if sync-token returns 403. Include `If-Match: <etag>` on CalDAV PUT; handle 412 (concurrent modification).
|
||||
|
||||
7. **iOS install guide is load-bearing** — iOS has no browser install prompt. The in-app guided flow (annotated screenshots: share icon → Add to Home Screen) is the only path to PWA installation and therefore push notifications for the wife.
|
||||
|
||||
## Implications for Roadmap
|
||||
|
||||
Suggested build order based on the dependency graph across all four research files:
|
||||
|
||||
### Phase 1: Foundation + CalDAV Broker Spike
|
||||
|
||||
**Rationale:** Nothing works without auth. The CalDAV broker is the riskiest unknown (personal calendar ACL) and must be validated before any calendar UI is built. Pangolin SSE/WS pass-through also must be confirmed before real-time sync is designed. Both are binary go/no-go decisions.
|
||||
|
||||
**Delivers:** Working Docker Compose stack; Authelia OIDC login; CalDAV broker read path confirmed against real Fastmail account; personal-calendar ACL spike result (go/no-go on the overlay feature); wife's calendar location confirmed (Fastmail vs iCloud); Pangolin SSE pass-through verified.
|
||||
|
||||
**Addresses:**
|
||||
- OIDC login — required by all other features
|
||||
- CalDAV protocol confirmed (JMAP ruled out)
|
||||
- Personal calendar sharing ACL confirmed or fallback decided
|
||||
- Infrastructure scaffold (Docker Compose, MariaDB schema, drizzle-kit migrations, .env)
|
||||
|
||||
**Pitfalls to avoid:**
|
||||
- JMAP assumption — confirm CalDAV endpoint returns events before proceeding
|
||||
- Email as identity key — use `oidc_iss + oidc_sub` from schema day one
|
||||
- Authelia groups claim — skip groups; all authenticated users are equal in this household
|
||||
- Pangolin WebSocket — smoke test SSE over public URL in this phase
|
||||
|
||||
**Research flag:** NEEDS research-phase — CalDAV ACL sharing mechanics and Pangolin WebSocket behavior are both implementation-dependent; cannot be resolved from documentation alone.
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Calendar Display
|
||||
|
||||
**Rationale:** Build read-only calendar UI on top of the confirmed broker. Validates broker correctness before adding write complexity. Color coding, multi-view layout, and RRULE display are substantial enough to be their own phase.
|
||||
|
||||
**Delivers:** Unified calendar view (month/week/day/agenda); per-member color coding; all-day event display; recurring event display via CALDAV:expand; no write-back yet.
|
||||
|
||||
**Addresses:**
|
||||
- Unified multi-calendar view
|
||||
- Per-member color coding
|
||||
- Day / week / month / agenda views
|
||||
- Recurring event display (RRULE + EXDATE)
|
||||
|
||||
**Pitfalls to avoid:**
|
||||
- All-day events as DATETIME — `allDay: true` flag + `DATE` column, no timezone coercion
|
||||
- RRULE client-side expansion only — use CALDAV:expand in REPORT; rrule.js for display window only
|
||||
- DST shift on recurring events — test with events crossing spring/fall DST boundary before milestone sign-off
|
||||
- Service worker cache-first for API routes — `Network-First` for `/api/*` routes
|
||||
|
||||
**Research flag:** Standard patterns; no research phase needed.
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Event Write-Back + PWA Install
|
||||
|
||||
**Rationale:** Adds create/edit/delete on top of confirmed read path. PWA installability is paired here because the service worker is required for both offline shell caching and push registration in Phase 5.
|
||||
|
||||
**Delivers:** Full CRUD events written back to Fastmail via CalDAV PUT; all-day and timed events; guided iOS Add to Home Screen onboarding flow; PWA manifest + service worker (injectManifest strategy).
|
||||
|
||||
**Addresses:**
|
||||
- Create / edit / delete events
|
||||
- PWA installability
|
||||
- iOS install guide (load-bearing for wife's push notifications)
|
||||
|
||||
**Pitfalls to avoid:**
|
||||
- Write-back without `If-Match` ETag — include on all PUT/DELETE; handle 412
|
||||
- Recurring RECURRENCE-ID corruption — v1 ships "edit all instances" only with clear label
|
||||
- Write-through cache — after PUT, re-fetch server version before caching
|
||||
- SW served with aggressive cache headers — `Cache-Control: no-cache` on `sw.js` and manifest
|
||||
- SW update staleness — in-app "new version available" prompt on SW `waiting` state
|
||||
|
||||
**Research flag:** Standard patterns; no research phase needed.
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: Shared Lists + Live Sync
|
||||
|
||||
**Rationale:** Lists domain is architecturally independent of the calendar broker (shares only auth and MariaDB). Serialized here to reduce WIP; could parallel-track with Phase 3 after Phase 1 is complete.
|
||||
|
||||
**Delivers:** Named shared lists; item CRUD (add/check/reorder/delete); live co-edit sync via SSE + optional Redis pub/sub; optimistic check-off; list sequence number for reconnect replay.
|
||||
|
||||
**Addresses:**
|
||||
- Shared lists CRUD
|
||||
- Live list co-edit sync
|
||||
- Reconnect gap handling
|
||||
|
||||
**Pitfalls to avoid:**
|
||||
- Missed updates during reconnect — monotonic sequence number per list in initial schema; server replays on reconnect
|
||||
- Pangolin SSE timeout — verify long-lived connections survive; configure proxy timeouts
|
||||
- WebSocket broadcast to all clients — filter SSE delivery by list subscription
|
||||
|
||||
**Research flag:** Standard patterns; no research phase needed.
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: Web Push Notifications
|
||||
|
||||
**Rationale:** Push is built last because it depends on events being cached (Phase 2), users having stable identity + push_subscriptions table (Phase 1), and the service worker being in place (Phase 3).
|
||||
|
||||
**Delivers:** VAPID key pair; push_subscriptions table; push permission request UX (inside tap handler only); reminder scheduler (events within 15-min window); event-change and list-change push alerts; subscription health-check on app open; 410/404 cleanup; Declarative Web Push compatible payload format.
|
||||
|
||||
**Addresses:**
|
||||
- Event reminders
|
||||
- List-change notifications
|
||||
- iOS push reliability
|
||||
|
||||
**Pitfalls to avoid:**
|
||||
- `showNotification()` without `event.waitUntil()` — three silent pushes = permanent subscription revocation on iOS
|
||||
- Fetch-before-display push handler — payload must be self-contained (title, body, URL); no API call inside push event handler
|
||||
- Dead subscription accumulation — handle 410 Gone immediately; delete record
|
||||
- EU DMA check — one-time: confirm both Apple IDs are non-EU; document result (expected: Canada, not affected)
|
||||
- Declarative Web Push payload format — define the JSON schema before implementing server-side push sender
|
||||
|
||||
**Research flag:** Standard patterns for VAPID; iOS-specific behavior is well-documented in PITFALLS.md. No research phase needed, but the `waitUntil` pattern and subscription health-check must be in the initial implementation.
|
||||
|
||||
---
|
||||
|
||||
### Phase Ordering Rationale
|
||||
|
||||
- Phase 1 (auth + broker spike) is an unconditional prerequisite — nothing works without auth, and the personal-calendar ACL spike is a binary gate for the product's primary differentiator
|
||||
- Phase 2 (calendar display) precedes Phase 3 (write-back) — validates broker correctness before adding write complexity
|
||||
- Phase 4 (lists) could parallel-track with Phase 3 — no calendar dependency; serialized here to reduce WIP
|
||||
- Phase 5 (push) is last by dependency: requires SW (Phase 3), event cache (Phase 2), stable user identity (Phase 1)
|
||||
- The personal-calendar overlay feature is gated on the Phase 1 ACL spike result. If the spike confirms sharing works, overlay ships in Phase 2. If the wife's calendar is on iCloud, the overlay is cut from v1 scope with shared-family-calendar-only as the fallback.
|
||||
|
||||
### Research Flags
|
||||
|
||||
**Phases needing deeper research during planning:**
|
||||
- **Phase 1 (CalDAV broker spike):** Fastmail personal-calendar CalDAV ACL sharing behavior — must be confirmed with a real account before Phase 2 calendar UI is built. Highest-risk item in the project.
|
||||
- **Phase 1 (infra spike):** Pangolin SSE/WebSocket pass-through — known issue #1034; confirm transport choice and configure timeouts before real-time sync is built.
|
||||
|
||||
**Phases with standard patterns (skip research phase):**
|
||||
- **Phase 2 (calendar display):** Calendar grid layout, CALDAV:expand, color coding — well-documented patterns.
|
||||
- **Phase 3 (write-back + PWA):** CalDAV PUT mechanics, vite-plugin-pwa injectManifest — well-documented.
|
||||
- **Phase 4 (lists + SSE):** REST CRUD + SSE + Redis pub/sub — standard patterns.
|
||||
- **Phase 5 (push):** VAPID, web-push npm, iOS-specific patterns — well-documented in community sources; implementation-sensitive but not research-sensitive.
|
||||
|
||||
## Confidence Assessment
|
||||
|
||||
| Area | Confidence | Notes |
|
||||
|------|------------|-------|
|
||||
| Stack | HIGH | Strong library convergence across all four research threads; official sources for Fastmail CalDAV-only decision |
|
||||
| Features | HIGH | Table stakes and anti-features well-evidenced across 5+ competing products; differentiators confirmed against PROJECT.md |
|
||||
| Architecture | MEDIUM | Broker-cache pattern well-established; personal-calendar ACL behavior is the LOW-confidence gap |
|
||||
| Pitfalls | MEDIUM-HIGH | CalDAV/RRULE/iOS push pitfalls well-documented in community; Fastmail-specific rate limits undocumented |
|
||||
|
||||
**Overall confidence:** MEDIUM-HIGH
|
||||
|
||||
### Gaps to Address
|
||||
|
||||
- **Personal calendar CalDAV ACL (critical):** Whether the broker token discovers the wife's personal calendar after Fastmail share+accept is unconfirmed. Must be spiked in Phase 1. If the wife uses iCloud Calendar as primary, personal overlay is impossible via CalDAV broker. Handle during Phase 1 planning: design the spike, define success criteria, define go/no-go decision point.
|
||||
|
||||
- **Wife's calendar location:** PROJECT.md assumes personal calendars are Fastmail-hosted. If the wife's primary calendar is iCloud, the unified view's differentiator is cut from v1. Confirm before Phase 2.
|
||||
|
||||
- **Fastmail CalDAV rate limits:** Not documented. 5-min ctag polling (one lightweight PROPFIND per calendar per tick) is conservative. Monitor for HTTP 429 in production; implement exponential backoff.
|
||||
|
||||
- **Declarative Web Push payload format:** `web-push` npm does not natively generate the `"web_push": 8030` JSON schema. VAPID remains the transport layer; the payload JSON must be hand-crafted to satisfy Declarative Web Push for iOS 18.4+. Define the payload schema in Phase 5 planning before implementing the server-side push sender.
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- [Fastmail API Documentation](https://www.fastmail.com/dev/) — CalDAV-only for calendars; JMAP calendar pending RFC 8984 finalization
|
||||
- [Fastmail App Passwords](https://www.fastmail.help/hc/en-us/articles/360058752854-App-passwords) — App password scope covers CalDAV; OAuth not applicable
|
||||
- [Fastmail Calendar Sharing](https://www.fastmail.help/hc/en-us/articles/1500000279781-Sharing-calendars-with-other-users) — ACL tiers confirmed; cross-account token access undocumented
|
||||
- [Authelia OIDC Clients Configuration](https://www.authelia.com/configuration/identity-providers/openid-connect/clients/) — PKCE, groups claim, breaking changes
|
||||
- [WebKit: Meet Declarative Web Push](https://webkit.org/blog/16535/meet-declarative-web-push/) — Safari 18.4+, ITP resilience
|
||||
- [CalDAV ctag Extension](https://github.com/apple/ccs-calendarserver/blob/master/doc/Extensions/caldav-ctag.txt) — ctag-based polling
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- [MagicBell: PWA iOS Limitations 2026](https://www.magicbell.com/blog/pwa-ios-limitations-safari-support-complete-guide) — iOS 16.4+, Home Screen required, EU DMA
|
||||
- [Sabre/DAV: Building a CalDAV client](https://sabre.io/dav/building-a-caldav-client/) — ETag, sync-token, VTIMEZONE pitfalls
|
||||
- [Pangolin WebSocket issue #1034](https://github.com/fosrl/pangolin/issues/1034) — Known WebSocket upgrade failure through tunnel
|
||||
- [Using Fastmail with CalDAV libraries](https://utf9k.net/blog/fastmail-caldav/) — Principal URL format, Cyrus IMAP internals
|
||||
- tsdav, ical.js, rrule, web-push, @hono/oidc-auth npm packages — versions and compatibility confirmed
|
||||
|
||||
### Tertiary (LOW confidence)
|
||||
- [Fastmail: Shared Calendaring Improvements](https://www.fastmail.com/blog/shared-calendar-improvements/) — CalDAV ACL standards alignment; cross-account broker token discovery unconfirmed
|
||||
- [iOS push subscriptions terminated after 3 notifications](https://dev.to/progressier/how-to-fix-ios-push-subscriptions-being-terminated-after-3-notifications-39a7) — `event.waitUntil()` requirement; community-sourced
|
||||
- [Home Assistant CalDAV all-day events UTC issue](https://github.com/home-assistant/core/issues/25814) — DATE vs DATETIME bug in production; real-world evidence
|
||||
|
||||
---
|
||||
*Research completed: 2026-06-03*
|
||||
*Ready for roadmap: yes*
|
||||
Reference in New Issue
Block a user