docs: complete project research (stack, features, architecture, pitfalls, summary)

This commit is contained in:
Lucas Berger
2026-06-03 15:01:33 -04:00
parent 1ce0348914
commit 0f79277942
5 changed files with 1740 additions and 0 deletions
+558
View File
@@ -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*