style(13-03): apply Prettier formatting across repo

Mechanical reformat — no logic changes. 398 files changed, 19125
insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc
(singleQuote:true, semi:true, tabWidth:2, trailingComma:all,
printWidth:100). Isolated per D-13-08 for reviewability.
This commit is contained in:
Lucas Berger
2026-06-11 20:35:18 -04:00
parent 4bc0445173
commit 982438dc10
398 changed files with 19050 additions and 16382 deletions
+84 -69
View File
@@ -55,27 +55,27 @@
### Component Responsibilities
| Component | File | Responsibility | v1.1 Status |
|-----------|------|----------------|-------------|
| Event form | `apps/pwa/src/components/EventForm.tsx` | Create/edit event UI | MODIFY: add reminder selector |
| Settings sheet | `apps/pwa/src/components/SettingsSheet.tsx` | Notifications toggle | MODIFY: add Admin section |
| API client | `apps/pwa/src/api/client.ts` | Typed fetch wrappers | MODIFY: admin + setup endpoints |
| Events route | `apps/api/src/routes/events.ts` | Calendar CRUD, outbox enqueue | MODIFY: pass reminder in payload, signal drain |
| Push route | `apps/api/src/routes/push.ts` | VAPID subscription management | UNCHANGED |
| VEVENT builder | `apps/api/src/broker/vevent.ts` | iCalendar string construction | MODIFY: add VALARM |
| CalDAV sync | `apps/api/src/broker/sync.ts` | Fastmail REPORT -> DB upsert | MODIFY: extract VALARM trigger |
| Outbox worker | `apps/api/src/broker/outboxWorker.ts` | CalDAV write-back drain | MODIFY: event-driven trigger subscription |
| Reminder scheduler | `apps/api/src/broker/reminderScheduler.ts` | Push reminders for events | MODIFY: variable VALARM-based lead |
| Poller | `apps/api/src/broker/poller.ts` | 5-min CalDAV sync | UNCHANGED |
| Crypto | `apps/api/src/broker/crypto.ts` | AES-256-GCM encrypt/decrypt | UNCHANGED (reused by admin) |
| DB schema | `apps/api/src/db/schema.ts` | Drizzle table definitions | MODIFY: is_admin, reminder_lead_minutes, app_config |
| Index / wiring | `apps/api/src/index.ts` | App bootstrap + worker startup | MODIFY: mount admin + setup routes |
| [NEW] Admin route | `apps/api/src/routes/admin.ts` | Role-gated credential + calendar mgmt | NEW |
| [NEW] Setup route | `apps/api/src/routes/setup.ts` | First-run wizard endpoints + validation | NEW |
| [NEW] Admin UI | `apps/pwa/src/components/AdminSettings.tsx` | Member credential UI, shared-cal picker | NEW |
| [NEW] Setup wizard | `apps/pwa/src/components/SetupWizard.tsx` | First-run guided bootstrap | NEW |
| [NEW] Outbox trigger | `apps/api/src/lib/outboxTrigger.ts` | In-process EventEmitter for drain signal | NEW |
| [NEW] CI workflow | `.gitea/workflows/ci.yml` | Lint/typecheck/test on PR | NEW |
| Component | File | Responsibility | v1.1 Status |
| -------------------- | ------------------------------------------- | ---------------------------------------- | --------------------------------------------------- |
| Event form | `apps/pwa/src/components/EventForm.tsx` | Create/edit event UI | MODIFY: add reminder selector |
| Settings sheet | `apps/pwa/src/components/SettingsSheet.tsx` | Notifications toggle | MODIFY: add Admin section |
| API client | `apps/pwa/src/api/client.ts` | Typed fetch wrappers | MODIFY: admin + setup endpoints |
| Events route | `apps/api/src/routes/events.ts` | Calendar CRUD, outbox enqueue | MODIFY: pass reminder in payload, signal drain |
| Push route | `apps/api/src/routes/push.ts` | VAPID subscription management | UNCHANGED |
| VEVENT builder | `apps/api/src/broker/vevent.ts` | iCalendar string construction | MODIFY: add VALARM |
| CalDAV sync | `apps/api/src/broker/sync.ts` | Fastmail REPORT -> DB upsert | MODIFY: extract VALARM trigger |
| Outbox worker | `apps/api/src/broker/outboxWorker.ts` | CalDAV write-back drain | MODIFY: event-driven trigger subscription |
| Reminder scheduler | `apps/api/src/broker/reminderScheduler.ts` | Push reminders for events | MODIFY: variable VALARM-based lead |
| Poller | `apps/api/src/broker/poller.ts` | 5-min CalDAV sync | UNCHANGED |
| Crypto | `apps/api/src/broker/crypto.ts` | AES-256-GCM encrypt/decrypt | UNCHANGED (reused by admin) |
| DB schema | `apps/api/src/db/schema.ts` | Drizzle table definitions | MODIFY: is_admin, reminder_lead_minutes, app_config |
| Index / wiring | `apps/api/src/index.ts` | App bootstrap + worker startup | MODIFY: mount admin + setup routes |
| [NEW] Admin route | `apps/api/src/routes/admin.ts` | Role-gated credential + calendar mgmt | NEW |
| [NEW] Setup route | `apps/api/src/routes/setup.ts` | First-run wizard endpoints + validation | NEW |
| [NEW] Admin UI | `apps/pwa/src/components/AdminSettings.tsx` | Member credential UI, shared-cal picker | NEW |
| [NEW] Setup wizard | `apps/pwa/src/components/SetupWizard.tsx` | First-run guided bootstrap | NEW |
| [NEW] Outbox trigger | `apps/api/src/lib/outboxTrigger.ts` | In-process EventEmitter for drain signal | NEW |
| [NEW] CI workflow | `.gitea/workflows/ci.yml` | Lint/typecheck/test on PR | NEW |
---
@@ -96,6 +96,7 @@ The reminder selector should be disabled or hidden when `allDay = true` (RFC 554
**`apps/pwa/src/api/client.ts`** — MODIFY
Extend `CreateEventPayload` type:
```typescript
reminderMinutes?: number // 0 = no alarm; positive = minutes before
```
@@ -103,8 +104,9 @@ reminderMinutes?: number // 0 = no alarm; positive = minutes before
**`apps/api/src/routes/events.ts`** — MODIFY
Extend `eventFieldsSchema` (line 100) and `outboxPayloadSchema` (line 71 in `outboxWorker.ts`):
```typescript
reminderMinutes: z.number().int().min(0).max(10080).optional() // max = 1 week
reminderMinutes: z.number().int().min(0).max(10080).optional(); // max = 1 week
```
The payload is JSON-stringified into `calendar_outbox.payload` (TEXT column). No outbox schema change needed.
@@ -115,14 +117,14 @@ Add `reminderMinutes?: number` to `NewEventParams` interface (line 21). In `buil
```typescript
if (!params.allDay && params.reminderMinutes !== undefined && params.reminderMinutes > 0) {
const valarm = new ICAL.Component('valarm')
valarm.addPropertyWithValue('action', 'DISPLAY')
valarm.addPropertyWithValue('description', params.summary)
const trigger = ICAL.Duration.fromSeconds(-(params.reminderMinutes * 60))
const triggerProp = new ICAL.Property('trigger')
triggerProp.setValue(trigger)
valarm.addProperty(triggerProp)
vevent.addSubcomponent(valarm)
const valarm = new ICAL.Component('valarm');
valarm.addPropertyWithValue('action', 'DISPLAY');
valarm.addPropertyWithValue('description', params.summary);
const trigger = ICAL.Duration.fromSeconds(-(params.reminderMinutes * 60));
const triggerProp = new ICAL.Property('trigger');
triggerProp.setValue(trigger);
valarm.addProperty(triggerProp);
vevent.addSubcomponent(valarm);
}
```
@@ -137,6 +139,7 @@ In both the `create` branch and `update` branch, pass `reminderMinutes: fields.r
**`apps/api/src/db/schema.ts`** — MODIFY
Add to `calendarEvents`:
```typescript
reminderLeadMinutes: int('reminder_lead_minutes'),
// nullable: NULL = no VALARM; positive int = minutes before dtstart
@@ -147,13 +150,14 @@ Add `drizzle-kit generate` migration. This is a nullable column addition — saf
**`apps/api/src/broker/sync.ts`** — MODIFY
When upserting a `calendarEvent`, extract VALARM from the parsed VEVENT:
```typescript
const valarm = vevent.getFirstSubcomponent('valarm')
let reminderLeadMinutes: number | null = null
const valarm = vevent.getFirstSubcomponent('valarm');
let reminderLeadMinutes: number | null = null;
if (valarm) {
const trigger = valarm.getFirstPropertyValue('trigger')
const trigger = valarm.getFirstPropertyValue('trigger');
if (trigger instanceof ICAL.Duration) {
reminderLeadMinutes = Math.abs(trigger.toSeconds()) / 60
reminderLeadMinutes = Math.abs(trigger.toSeconds()) / 60;
}
}
```
@@ -163,21 +167,24 @@ Store `reminderLeadMinutes` in the DB upsert. This is the ground truth — the s
**`apps/api/src/broker/reminderScheduler.ts`** — MODIFY (significant rewrite of `runReminderCheck`)
Current behavior:
- Fixed 16-minute catch-up window: `dtstartUtc IN (now, now+16min]`
- Fires only for `calendars.isShared = true` (D-05 constraint)
- Dedup key: bare `uid` in `sentReminders: Map<string, number>`
New behavior:
- Variable lead: query events where the alarm-fire-time falls in the current tick's window
- Alarm-fire-time = `dtstartUtc - INTERVAL reminder_lead_minutes MINUTE`
- Window: `(now - 1min, now + 1min]` gives a 2-minute catch-up on missed ticks (scheduler runs every 60s)
- Events with `reminder_lead_minutes IS NULL` are excluded — no VALARM means no reminder
Drizzle raw SQL expression for the window predicate:
```typescript
sql`${calendarEvents.reminderLeadMinutes} IS NOT NULL
AND DATE_SUB(${calendarEvents.dtstartUtc}, INTERVAL ${calendarEvents.reminderLeadMinutes} MINUTE) > ${new Date(now.getTime() - 60_000)}
AND DATE_SUB(${calendarEvents.dtstartUtc}, INTERVAL ${calendarEvents.reminderLeadMinutes} MINUTE) <= ${new Date(now.getTime() + 60_000)}`
AND DATE_SUB(${calendarEvents.dtstartUtc}, INTERVAL ${calendarEvents.reminderLeadMinutes} MINUTE) <= ${new Date(now.getTime() + 60_000)}`;
```
`isShared` restriction (D-05): this is a scope decision. D-05 was originally "shared calendar only" for the fixed-15-min reminder. With per-event VALARM, the user explicitly configured a reminder — it should fire regardless of which calendar the event is on. Recommend removing the `isShared` restriction in v1.1 for reminder-triggered notifications. Event-change notifications (via `eventChangeDispatcher.ts`) are a separate flow and unaffected.
@@ -200,16 +207,16 @@ New file `apps/api/src/lib/outboxTrigger.ts` — follows the exact pattern of `l
```typescript
// apps/api/src/lib/outboxTrigger.ts
import { EventEmitter } from 'node:events'
const emitter = new EventEmitter()
import { EventEmitter } from 'node:events';
const emitter = new EventEmitter();
export function signalOutboxDrain(): void {
emitter.emit('drain')
emitter.emit('drain');
}
export function onOutboxDrainSignal(handler: () => void): () => void {
emitter.on('drain', handler)
return () => emitter.off('drain', handler)
emitter.on('drain', handler);
return () => emitter.off('drain', handler);
}
```
@@ -218,8 +225,9 @@ export function onOutboxDrainSignal(handler: () => void): () => void {
**`apps/api/src/routes/events.ts`** — MODIFY
After each `db.insert(calendarOutbox)` in the three write handlers (create, edit/same-calendar, edit/move transaction), add:
```typescript
signalOutboxDrain()
signalOutboxDrain();
```
Import from `'../lib/outboxTrigger.js'`. Fire-and-forget; no await.
@@ -227,24 +235,25 @@ Import from `'../lib/outboxTrigger.js'`. Fire-and-forget; no await.
**`apps/api/src/broker/outboxWorker.ts`** — MODIFY
In `startOutboxWorker()`:
```typescript
export function startOutboxWorker(): void {
// Event-driven: drain immediately after any enqueue signal
onOutboxDrainSignal(() => {
runOutboxDrain().catch((err: unknown) => {
console.error('[outboxWorker] Signal-triggered drain error:', err)
})
})
console.error('[outboxWorker] Signal-triggered drain error:', err);
});
});
// Fallback: 15s periodic drain for recovery + startup catch-up
setInterval(() => {
runOutboxDrain().catch((err: unknown) => {
console.error('[outboxWorker] Interval drain error:', err)
})
}, 15 * 1000)
console.error('[outboxWorker] Interval drain error:', err);
});
}, 15 * 1000);
}
```
**All durability guarantees are preserved.** The `isDraining` concurrency guard handles overlapping invocations from both the signal and the interval. When a signal-triggered drain is running (`isDraining = true`), any concurrent call (signal or interval) is a no-op. Create-before-delete ordering (D-04), etag re-read (WR-02), 412 conflict flow (D-08), transient backoff (D-07) — all unchanged. The trigger path only affects *when* `runOutboxDrain()` is called.
**All durability guarantees are preserved.** The `isDraining` concurrency guard handles overlapping invocations from both the signal and the interval. When a signal-triggered drain is running (`isDraining = true`), any concurrent call (signal or interval) is a no-op. Create-before-delete ordering (D-04), etag re-read (WR-02), 412 conflict flow (D-08), transient backoff (D-07) — all unchanged. The trigger path only affects _when_ `runOutboxDrain()` is called.
**Rapid-successive-edits behavior:** Each `signalOutboxDrain()` call may trigger a drain that processes row N while row N+1 hasn't been inserted yet. Row N+1's `nextAttemptAt = defaultNow()`, so it appears in the next drain cycle's `pending + next_attempt_at <= NOW()` query. The 15s fallback interval ensures it is drained within 15 seconds at most.
@@ -255,6 +264,7 @@ export function startOutboxWorker(): void {
#### Admin role flag
**Where:** `users` table in `apps/api/src/db/schema.ts`. Add:
```typescript
isAdmin: boolean('is_admin').default(false).notNull(),
```
@@ -271,20 +281,21 @@ isAdmin: boolean('is_admin').default(false).notNull(),
The wizard bootstraps four categories of state:
| Config | Current Home | v1.1 Approach |
|--------|-------------|----------------|
| VAPID keypair | `.env` (VAPID_PUBLIC_KEY, _PRIVATE_KEY, _SUBJECT) | Stays in env; wizard validates presence + offers generate-and-display |
| AES-256 key | `.env` (APP_PASSWORD_ENCRYPTION_KEY) | Stays in env; wizard validates format (64-char hex) |
| OIDC credentials | `.env` (OIDC_ISSUER, _CLIENT_ID, _SECRET, etc.) | Stays in env; wizard validates OIDC discovery reachability |
| App passwords + shared-cal | DB (member_credentials, calendars.is_shared) | Same DB; wizard is the UI for what was previously a manual DB write |
| Config | Current Home | v1.1 Approach |
| -------------------------- | --------------------------------------------------- | --------------------------------------------------------------------- |
| VAPID keypair | `.env` (VAPID_PUBLIC_KEY, \_PRIVATE_KEY, \_SUBJECT) | Stays in env; wizard validates presence + offers generate-and-display |
| AES-256 key | `.env` (APP_PASSWORD_ENCRYPTION_KEY) | Stays in env; wizard validates format (64-char hex) |
| OIDC credentials | `.env` (OIDC_ISSUER, \_CLIENT_ID, \_SECRET, etc.) | Stays in env; wizard validates OIDC discovery reachability |
| App passwords + shared-cal | DB (member_credentials, calendars.is_shared) | Same DB; wizard is the UI for what was previously a manual DB write |
**New `app_config` table** for app-level flags:
```typescript
export const appConfig = mysqlTable('app_config', {
key: varchar('key', { length: 128 }).primaryKey(),
value: text('value').notNull(),
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(),
})
});
```
Initial keys: `setup_complete` (`'0'` or `'1'`), `setup_step` (resumable flow state).
@@ -294,6 +305,7 @@ Initial keys: `setup_complete` (`'0'` or `'1'`), `setup_step` (resumable flow st
**`apps/api/src/routes/setup.ts`** — NEW
Endpoints (all except `/status` are admin-only after first-run completes):
- `GET /api/setup/status`**unauthenticated** (mounted before OIDC guard in `index.ts`, like `/health`); returns `{ complete: boolean }`
- `GET /api/setup/env-status` — returns which env vars are present/missing (no values)
- `POST /api/setup/generate-vapid` — calls `webpush.generateVAPIDKeys()`, returns `{ publicKey, privateKey, subject }` for operator to copy; does NOT store in DB
@@ -449,9 +461,11 @@ This means `reminder_lead_minutes` in `calendar_events` is the ground truth. Eve
### First-run detection: mount `/api/setup/status` before OIDC guard
In `apps/api/src/index.ts`, `/health` is already mounted before the OIDC guard (line 40). Mount `GET /api/setup/status` with the same pattern:
```typescript
app.get('/api/setup/status', setupStatusHandler) // before oidcAuthMiddleware
app.get('/api/setup/status', setupStatusHandler); // before oidcAuthMiddleware
```
This lets the PWA detect first-run state before the OIDC redirect fires and render a setup splash instead of an auth bounce loop.
---
@@ -543,21 +557,21 @@ The `isDraining` module-level boolean guard is the correct concurrency mechanism
### External Services
| Service | Integration | v1.1 Notes |
|---------|-------------|------------|
| Service | Integration | v1.1 Notes |
| --------------- | ------------------------------------------ | ------------------------------------------------------------------------------ |
| Fastmail CalDAV | tsdav PUT with VCALENDAR containing VALARM | VALARM is inert to Fastmail's CalDAV server (stored as-is); sync reads it back |
| Authelia OIDC | No change | Setup wizard is post-OIDC; admin routes use existing OIDC session |
| Authelia OIDC | No change | Setup wizard is post-OIDC; admin routes use existing OIDC session |
### Internal Module Boundaries
| Boundary | Communication | Notes |
|----------|---------------|-------|
| `routes/events.ts``outboxWorker.ts` | `signalOutboxDrain()` via EventEmitter | NEW: fire-and-forget, one-way |
| `routes/admin.ts``broker/crypto.ts` | Direct import of `encryptPassword` | REUSE existing module — no changes to crypto.ts |
| `routes/setup.ts``db/schema.ts` appConfig | Drizzle queries | NEW table, same db client and connection pool |
| `broker/vevent.ts``ical.js` | `ICAL.Component('valarm')`, `ICAL.Duration.fromSeconds` | EXTEND existing ical.js usage |
| `broker/sync.ts``db/schema.ts` | Store `reminderLeadMinutes` in events upsert | MODIFY existing upsert path in syncCalendar |
| `reminderScheduler.ts``db/schema.ts` | Read `reminder_lead_minutes` in Drizzle query | MODIFY existing query; raw SQL expression |
| Boundary | Communication | Notes |
| -------------------------------------------- | ------------------------------------------------------- | ----------------------------------------------- |
| `routes/events.ts``outboxWorker.ts` | `signalOutboxDrain()` via EventEmitter | NEW: fire-and-forget, one-way |
| `routes/admin.ts``broker/crypto.ts` | Direct import of `encryptPassword` | REUSE existing module — no changes to crypto.ts |
| `routes/setup.ts``db/schema.ts` appConfig | Drizzle queries | NEW table, same db client and connection pool |
| `broker/vevent.ts``ical.js` | `ICAL.Component('valarm')`, `ICAL.Duration.fromSeconds` | EXTEND existing ical.js usage |
| `broker/sync.ts``db/schema.ts` | Store `reminderLeadMinutes` in events upsert | MODIFY existing upsert path in syncCalendar |
| `reminderScheduler.ts``db/schema.ts` | Read `reminder_lead_minutes` in Drizzle query | MODIFY existing query; raw SQL expression |
---
@@ -579,5 +593,6 @@ All findings are grounded in direct codebase inspection:
- `apps/api/tests/` — test directory layout confirms tests in `tests/`, not `src/`; `helpers/db.ts` pattern
---
*Architecture research for: FamilySync v1.1 Operability & Polish*
*Researched: 2026-06-10*
_Architecture research for: FamilySync v1.1 Operability & Polish_
_Researched: 2026-06-10_
+78 -73
View File
@@ -18,42 +18,42 @@ This document covers **only the six v1.1 features**. v1.0 features (calendar, ev
Features that must exist in v1.1 to avoid the product feeling unfinished for real household use.
| Feature | Why Expected | Complexity | Notes |
|---------|--------------|------------|-------|
| Per-event reminder selector on event form | Every calendar app (Apple, Google, Fastmail) has this. The current hardcoded 15-min is a bug, not a feature. | MEDIUM | Drop-down of offsets (None / 5 min / 10 min / 15 min / 30 min / 1 hour / 2 hours / 1 day / 2 days) written as VALARM TRIGGER:-PTxM/H or TRIGGER:-P1D in the .ics. "None" default = no VALARM element emitted. |
| "None" is the default alarm state | Apple Calendar defaults new events to "None" alert unless the user has changed their Calendar > Settings > Alerts default. Google Calendar defaults to "30 minutes". The PWA should mirror "None" as the explicit no-alarm state — no alarm = no push. | LOW | Must not silently inherit a global default from Fastmail's own app-password user preferences. Emit no VALARM when "None". |
| Preserve existing VALARMs on edit | If an event was created in Apple Calendar or Fastmail's native client with a specific reminder, editing it in the PWA must not silently strip that reminder. | MEDIUM | tsdav + ical.js round-trip: parse VALARM on fetch, display the closest-matching preset (or "custom" fallback), write back on save. Explicitly handle the case where an existing VALARM is not in the preset list. |
| Scheduler honors per-event lead | The push scheduler must fire at `event_start - trigger_offset`, not a hardcoded 15 min. If no VALARM, fire nothing. | MEDIUM | Depends on outbox/scheduler already built in v1.0. Requires storing the absolute fire-time in the DB so the scheduler does not re-parse ical on every tick. |
| Admin Settings UI (role-gated) | Self-hosted apps cannot require SSH/DB-console access for routine admin. The two tasks (rotate app password, mark shared calendar) are operator-level but must be doable from the browser. | MEDIUM | Single admin flag on the users row; admin sees a Settings section hidden from the other member. Both tasks are currently manual DB writes — table stakes to remove that dependency. |
| Initial setup wizard (first run) | Without a wizard, first-time deploy requires hand-editing env files in the right order, running VAPID key generation manually, and hoping the DB connection string is correct. Nextcloud/Gitea/Authelia all ship a first-run wizard for exactly this reason. | HIGH | Gate on a `setup_complete` flag persisted in the DB or a dotfile. Must validate each credential before advancing: DB ping, OIDC discovery endpoint reachable, VAPID keys structurally valid, app password CalDAV test connection. |
| Event-driven outbox drain | Current ~15s latency (polling interval) makes edits feel sluggish when the user sees the event unchanged for 10+ seconds after saving. Every other calendar app (Google, Apple, Fastmail native) round-trips writes in under 2 seconds perceived. | MEDIUM | The existing transactional outbox guarantees durability. The fix is to trigger an immediate drain on INSERT to the outbox table, rather than waiting for the next scheduled tick. |
| Feature | Why Expected | Complexity | Notes |
| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Per-event reminder selector on event form | Every calendar app (Apple, Google, Fastmail) has this. The current hardcoded 15-min is a bug, not a feature. | MEDIUM | Drop-down of offsets (None / 5 min / 10 min / 15 min / 30 min / 1 hour / 2 hours / 1 day / 2 days) written as VALARM TRIGGER:-PTxM/H or TRIGGER:-P1D in the .ics. "None" default = no VALARM element emitted. |
| "None" is the default alarm state | Apple Calendar defaults new events to "None" alert unless the user has changed their Calendar > Settings > Alerts default. Google Calendar defaults to "30 minutes". The PWA should mirror "None" as the explicit no-alarm state — no alarm = no push. | LOW | Must not silently inherit a global default from Fastmail's own app-password user preferences. Emit no VALARM when "None". |
| Preserve existing VALARMs on edit | If an event was created in Apple Calendar or Fastmail's native client with a specific reminder, editing it in the PWA must not silently strip that reminder. | MEDIUM | tsdav + ical.js round-trip: parse VALARM on fetch, display the closest-matching preset (or "custom" fallback), write back on save. Explicitly handle the case where an existing VALARM is not in the preset list. |
| Scheduler honors per-event lead | The push scheduler must fire at `event_start - trigger_offset`, not a hardcoded 15 min. If no VALARM, fire nothing. | MEDIUM | Depends on outbox/scheduler already built in v1.0. Requires storing the absolute fire-time in the DB so the scheduler does not re-parse ical on every tick. |
| Admin Settings UI (role-gated) | Self-hosted apps cannot require SSH/DB-console access for routine admin. The two tasks (rotate app password, mark shared calendar) are operator-level but must be doable from the browser. | MEDIUM | Single admin flag on the users row; admin sees a Settings section hidden from the other member. Both tasks are currently manual DB writes — table stakes to remove that dependency. |
| Initial setup wizard (first run) | Without a wizard, first-time deploy requires hand-editing env files in the right order, running VAPID key generation manually, and hoping the DB connection string is correct. Nextcloud/Gitea/Authelia all ship a first-run wizard for exactly this reason. | HIGH | Gate on a `setup_complete` flag persisted in the DB or a dotfile. Must validate each credential before advancing: DB ping, OIDC discovery endpoint reachable, VAPID keys structurally valid, app password CalDAV test connection. |
| Event-driven outbox drain | Current ~15s latency (polling interval) makes edits feel sluggish when the user sees the event unchanged for 10+ seconds after saving. Every other calendar app (Google, Apple, Fastmail native) round-trips writes in under 2 seconds perceived. | MEDIUM | The existing transactional outbox guarantees durability. The fix is to trigger an immediate drain on INSERT to the outbox table, rather than waiting for the next scheduled tick. |
### Differentiators (Competitive Advantage for This Product)
Features that go beyond what a user would minimally expect — meaningful for this specific 2-person self-hosted context.
| Feature | Value Proposition | Complexity | Notes |
|---------|-------------------|------------|-------|
| Multiple reminders per event (up to 2) | Apple Calendar supports multiple alerts per event. The iCalendar spec (RFC 5545) allows multiple VALARM components in one VEVENT. Most household events benefit from a "1 day before" + "30 min before" pair. | MEDIUM | Add a second optional reminder offset selector on the form. Emit two VALARM blocks in the .ics. Parse up to 2 existing VALARMs. Do not expose this in v1.1 if it risks slipping the phase; single-alarm is the floor. |
| All-day event reminder semantics matching Apple Calendar | Apple Calendar fires all-day alerts at 9 AM on the alert day (same day or 1 day before, etc.), not at 00:00. Google Calendar fires at 11:50 PM the night before for a "10-min" all-day offset, which is jarring. The Apple convention (morning-of) is the correct UX for this household's non-technical Apple member. | LOW | When the event is all-day and the reminder offset is "on the day" or "1 day before", the VALARM TRIGGER is written as a day-relative offset (`TRIGGER:-P1D` or `TRIGGER:P0D`). The scheduler fires at 9:00 AM on the resolved day (not midnight). This is a scheduler config constant, not user-settable. |
| CI regression gating on PR | Prevents the class of regressions introduced during v1.0 (tsc passes but runtime breaks, API test failures not caught until manual verify). Gitea Actions is already available on the self-hosted Gitea instance. | MEDIUM | Lint + typecheck (both apps) + unit tests (Vitest) + API integration tests against a MariaDB service container. Fails PR merge if any step fails. Significantly reduces the human verification burden per phase. |
| Mobile-emulated Playwright test harness | The CLAUDE.md preference is to use playwright-cli for validation rather than asking the operator. A mobile-emulated (iPhone viewport, touch, user-agent) authenticated harness lets the assistant catch mobile-only layout regressions, modal overflow, and form usability issues before handing off for iOS-hardware verification. | MEDIUM | Playwright `devices['iPhone 15']` or equivalent device preset, session reuse via `storageState`, DEV_AUTH_BYPASS=true for CI. Does not replace real-device iOS tests (push, standalone mode). |
| Docker image publish from CI | Currently the image is built manually. Auto-publishing on merge to main means Unraid can pull the latest image without an SSH session. | LOW | Gitea Packages registry or Docker Hub. Triggered on PR merge to main (not on every PR). |
| Feature | Value Proposition | Complexity | Notes |
| -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Multiple reminders per event (up to 2) | Apple Calendar supports multiple alerts per event. The iCalendar spec (RFC 5545) allows multiple VALARM components in one VEVENT. Most household events benefit from a "1 day before" + "30 min before" pair. | MEDIUM | Add a second optional reminder offset selector on the form. Emit two VALARM blocks in the .ics. Parse up to 2 existing VALARMs. Do not expose this in v1.1 if it risks slipping the phase; single-alarm is the floor. |
| All-day event reminder semantics matching Apple Calendar | Apple Calendar fires all-day alerts at 9 AM on the alert day (same day or 1 day before, etc.), not at 00:00. Google Calendar fires at 11:50 PM the night before for a "10-min" all-day offset, which is jarring. The Apple convention (morning-of) is the correct UX for this household's non-technical Apple member. | LOW | When the event is all-day and the reminder offset is "on the day" or "1 day before", the VALARM TRIGGER is written as a day-relative offset (`TRIGGER:-P1D` or `TRIGGER:P0D`). The scheduler fires at 9:00 AM on the resolved day (not midnight). This is a scheduler config constant, not user-settable. |
| CI regression gating on PR | Prevents the class of regressions introduced during v1.0 (tsc passes but runtime breaks, API test failures not caught until manual verify). Gitea Actions is already available on the self-hosted Gitea instance. | MEDIUM | Lint + typecheck (both apps) + unit tests (Vitest) + API integration tests against a MariaDB service container. Fails PR merge if any step fails. Significantly reduces the human verification burden per phase. |
| Mobile-emulated Playwright test harness | The CLAUDE.md preference is to use playwright-cli for validation rather than asking the operator. A mobile-emulated (iPhone viewport, touch, user-agent) authenticated harness lets the assistant catch mobile-only layout regressions, modal overflow, and form usability issues before handing off for iOS-hardware verification. | MEDIUM | Playwright `devices['iPhone 15']` or equivalent device preset, session reuse via `storageState`, DEV_AUTH_BYPASS=true for CI. Does not replace real-device iOS tests (push, standalone mode). |
| Docker image publish from CI | Currently the image is built manually. Auto-publishing on merge to main means Unraid can pull the latest image without an SSH session. | LOW | Gitea Packages registry or Docker Hub. Triggered on PR merge to main (not on every PR). |
### Anti-Features (Explicitly Exclude)
Features that appear reasonable but are wrong for a 2-person self-hosted household. Flag these as scope creep.
| Feature | Why Requested | Why to Exclude | What to Do Instead |
|---------|---------------|----------------|--------------------|
| User-facing notification preferences page (per-member mute, granular notification types) | Every commercial app has this. Feels like a natural extension of reminder settings. | Two users, both presumably want reminders. This adds a settings surface that 100% of users must click through and that creates support burden (why am I not getting reminders?). The non-technical member should never need to configure this. | Set sensible defaults (all push types on) and never ask. If one member doesn't want push, they decline the browser permission prompt — the OS handles it. |
| Reminder "snooze" in the notification payload | RFC 9074 defines a snooze mechanism via sibling VALARM components. Fantastical supports it. | Implementing reliable snooze requires creating a new VALARM in the .ics (PUT back to Fastmail) from the service worker notification click handler. That is a write path triggered from a background service worker — a significant reliability and complexity risk. | Dismiss and re-add a reminder manually if needed. The use case is too rare for this household to justify the implementation risk. |
| Setup wizard re-run / reset | Advanced users might want to re-run parts of the wizard (e.g., rotate VAPID keys). | For a 2-person deployment, the operator can edit the env file or use the Admin Settings page for app-password rotation. A re-runnable wizard adds state-management complexity (partial completion, rollback). | Admin Settings covers the post-setup operational cases (app password rotation, shared calendar designation). VAPID key rotation is a documented manual step (generate, update env, redeploy). |
| Full audit log in Admin Settings | Some self-hosted admin panels (Gitea, Nextcloud) ship an audit log of administrative actions. | Two users. Both equal operators. There is no adversarial scenario between two family members that requires an audit trail. | N/A — omit entirely. |
| Calendar provider abstraction / plugin system | Would make the admin panel a "configure any CalDAV provider" experience. | The constraint is Fastmail + this specific household. A provider abstraction layer adds 3x the surface area for zero current benefit. | Hard-code Fastmail CalDAV principal discovery. Document the URL in the admin panel for transparency. If a second provider is ever needed, add it as a targeted feature in v2. |
| Self-service member onboarding via wizard | The setup wizard bootstraps the operator. Member onboarding (per-member app password collection) is a distinct problem (backlog 999.5). | Mixing these into one wizard creates a flow that only the operator completes — the other member would encounter a half-configured wizard. | Keep operator setup wizard and member onboarding as separate concerns. Admin Settings covers the operator side of member credential management. |
| Health dashboard / status page in Admin Settings | Uptime graphs, service health indicators, DB query stats. Seen in Nextcloud admin. | Single Docker host, two users. If the app is down, both users know immediately. There is no ops team monitoring this. | Docker logs + Unraid dashboard are sufficient. The setup wizard validates connectivity once; that is the only point of truth needed. |
| E2E test suite that runs on real iOS Safari | Complete mobile coverage in CI | Real iOS Safari requires physical device or paid cloud service (BrowserStack). Not reproducible in a self-hosted Gitea runner. | Playwright mobile emulation covers layout/interaction. Real-device iOS tests remain a human gate for push + standalone mode (as in v1.0). |
| Feature | Why Requested | Why to Exclude | What to Do Instead |
| ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| User-facing notification preferences page (per-member mute, granular notification types) | Every commercial app has this. Feels like a natural extension of reminder settings. | Two users, both presumably want reminders. This adds a settings surface that 100% of users must click through and that creates support burden (why am I not getting reminders?). The non-technical member should never need to configure this. | Set sensible defaults (all push types on) and never ask. If one member doesn't want push, they decline the browser permission prompt — the OS handles it. |
| Reminder "snooze" in the notification payload | RFC 9074 defines a snooze mechanism via sibling VALARM components. Fantastical supports it. | Implementing reliable snooze requires creating a new VALARM in the .ics (PUT back to Fastmail) from the service worker notification click handler. That is a write path triggered from a background service worker — a significant reliability and complexity risk. | Dismiss and re-add a reminder manually if needed. The use case is too rare for this household to justify the implementation risk. |
| Setup wizard re-run / reset | Advanced users might want to re-run parts of the wizard (e.g., rotate VAPID keys). | For a 2-person deployment, the operator can edit the env file or use the Admin Settings page for app-password rotation. A re-runnable wizard adds state-management complexity (partial completion, rollback). | Admin Settings covers the post-setup operational cases (app password rotation, shared calendar designation). VAPID key rotation is a documented manual step (generate, update env, redeploy). |
| Full audit log in Admin Settings | Some self-hosted admin panels (Gitea, Nextcloud) ship an audit log of administrative actions. | Two users. Both equal operators. There is no adversarial scenario between two family members that requires an audit trail. | N/A — omit entirely. |
| Calendar provider abstraction / plugin system | Would make the admin panel a "configure any CalDAV provider" experience. | The constraint is Fastmail + this specific household. A provider abstraction layer adds 3x the surface area for zero current benefit. | Hard-code Fastmail CalDAV principal discovery. Document the URL in the admin panel for transparency. If a second provider is ever needed, add it as a targeted feature in v2. |
| Self-service member onboarding via wizard | The setup wizard bootstraps the operator. Member onboarding (per-member app password collection) is a distinct problem (backlog 999.5). | Mixing these into one wizard creates a flow that only the operator completes — the other member would encounter a half-configured wizard. | Keep operator setup wizard and member onboarding as separate concerns. Admin Settings covers the operator side of member credential management. |
| Health dashboard / status page in Admin Settings | Uptime graphs, service health indicators, DB query stats. Seen in Nextcloud admin. | Single Docker host, two users. If the app is down, both users know immediately. There is no ops team monitoring this. | Docker logs + Unraid dashboard are sufficient. The setup wizard validates connectivity once; that is the only point of truth needed. |
| E2E test suite that runs on real iOS Safari | Complete mobile coverage in CI | Real iOS Safari requires physical device or paid cloud service (BrowserStack). Not reproducible in a self-hosted Gitea runner. | Playwright mobile emulation covers layout/interaction. Real-device iOS tests remain a human gate for push + standalone mode (as in v1.0). |
---
@@ -130,21 +130,22 @@ Features that appear reasonable but are wrong for a 2-person self-hosted househo
## Feature Prioritization Matrix
| Feature | User Value | Implementation Cost | Priority |
|---------|------------|---------------------|----------|
| Per-event reminder selector | HIGH | MEDIUM | P1 |
| Scheduler per-event VALARM | HIGH | MEDIUM | P1 |
| Faster write-back | HIGH | MEDIUM | P1 |
| Admin Settings — app password mgmt | HIGH | MEDIUM | P1 |
| Admin Settings — shared calendar toggle | HIGH | LOW | P1 |
| Setup wizard | HIGH | HIGH | P1 |
| Gitea CI (regression gate) | MEDIUM | MEDIUM | P1 |
| Mobile Playwright harness | MEDIUM | LOW | P2 |
| Multiple reminders per event | MEDIUM | MEDIUM | P2 |
| All-day reminder at 9 AM semantics | MEDIUM | LOW | P2 |
| Docker image auto-publish | LOW | LOW | P3 |
| Feature | User Value | Implementation Cost | Priority |
| --------------------------------------- | ---------- | ------------------- | -------- |
| Per-event reminder selector | HIGH | MEDIUM | P1 |
| Scheduler per-event VALARM | HIGH | MEDIUM | P1 |
| Faster write-back | HIGH | MEDIUM | P1 |
| Admin Settings — app password mgmt | HIGH | MEDIUM | P1 |
| Admin Settings — shared calendar toggle | HIGH | LOW | P1 |
| Setup wizard | HIGH | HIGH | P1 |
| Gitea CI (regression gate) | MEDIUM | MEDIUM | P1 |
| Mobile Playwright harness | MEDIUM | LOW | P2 |
| Multiple reminders per event | MEDIUM | MEDIUM | P2 |
| All-day reminder at 9 AM semantics | MEDIUM | LOW | P2 |
| Docker image auto-publish | LOW | LOW | P3 |
**Priority key:**
- P1: Must have for milestone claim
- P2: High value, ship if no risk to P1
- P3: Nice to have, defer
@@ -157,17 +158,17 @@ Features that appear reasonable but are wrong for a 2-person self-hosted househo
**Reminder selector options (matching Apple Calendar + Google Calendar intersection):**
| Label | VALARM TRIGGER value | Notes |
|-------|---------------------|-------|
| None | (no VALARM emitted) | Default for new events |
| 5 minutes before | `TRIGGER:-PT5M` | |
| 10 minutes before | `TRIGGER:-PT10M` | |
| 15 minutes before | `TRIGGER:-PT15M` | Current hardcoded default — becomes an explicit choice |
| 30 minutes before | `TRIGGER:-PT30M` | |
| 1 hour before | `TRIGGER:-PT1H` | |
| 2 hours before | `TRIGGER:-PT2H` | |
| 1 day before | `TRIGGER:-P1D` | |
| 2 days before | `TRIGGER:-P2D` | |
| Label | VALARM TRIGGER value | Notes |
| ----------------- | -------------------- | ------------------------------------------------------ |
| None | (no VALARM emitted) | Default for new events |
| 5 minutes before | `TRIGGER:-PT5M` | |
| 10 minutes before | `TRIGGER:-PT10M` | |
| 15 minutes before | `TRIGGER:-PT15M` | Current hardcoded default — becomes an explicit choice |
| 30 minutes before | `TRIGGER:-PT30M` | |
| 1 hour before | `TRIGGER:-PT1H` | |
| 2 hours before | `TRIGGER:-PT2H` | |
| 1 day before | `TRIGGER:-P1D` | |
| 2 days before | `TRIGGER:-P2D` | |
**All-day event semantics:** When saving an all-day event with any reminder, the scheduler fires at 09:00 AM local time on the target day (computed as `allday_date + offset_days`). Do not fire at midnight. Apple Calendar uses 9 AM as the "on the day" time for all-day alerts; this matches the wife's expectation.
@@ -193,18 +194,18 @@ Features that appear reasonable but are wrong for a 2-person self-hosted househo
**Step order (each step validates before advancing):**
| Step | Fields | Validation |
|------|--------|------------|
| 1. Welcome | None — explains what the wizard does | None |
| 2. Database | Already connected (MariaDB creds are in env at container start). Show "connected" status. | DB ping; show error + instructions if failed |
| 3. App URL | External URL (used for OIDC redirect) | URL format check; attempt a `HEAD` to itself if reachable |
| 4. OIDC | Client ID, client secret, issuer URL, redirect URI (pre-filled) | Fetch `{issuer}/.well-known/openid-configuration`; show discovered endpoints; fail if unreachable |
| 5. Session secret | Auto-generated 32-byte hex string (user can override) | Length >= 32 chars |
| 6. Encryption key | Auto-generated 32-byte hex string for `APP_PASSWORD_ENCRYPTION_KEY` | Length == 32 bytes |
| 7. VAPID keys | Auto-generate button (calls `webpush.generateVAPIDKeys()`) | Structural check — public key is a valid base64url-encoded P-256 point |
| 8. Admin account | Select from OIDC-discovered members OR enter the sub/preferred_username manually | Not empty |
| 9. Fastmail app password | App password for the primary calendar account | Test CalDAV `PROPFIND` to `https://caldav.fastmail.com/dav/principals/user/<email>/`; show pass/fail |
| 10. Confirm + save | Summary of all inputs | Writes config to DB/env; sets `setup_complete`; redirects to app |
| Step | Fields | Validation |
| ------------------------ | ----------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| 1. Welcome | None — explains what the wizard does | None |
| 2. Database | Already connected (MariaDB creds are in env at container start). Show "connected" status. | DB ping; show error + instructions if failed |
| 3. App URL | External URL (used for OIDC redirect) | URL format check; attempt a `HEAD` to itself if reachable |
| 4. OIDC | Client ID, client secret, issuer URL, redirect URI (pre-filled) | Fetch `{issuer}/.well-known/openid-configuration`; show discovered endpoints; fail if unreachable |
| 5. Session secret | Auto-generated 32-byte hex string (user can override) | Length >= 32 chars |
| 6. Encryption key | Auto-generated 32-byte hex string for `APP_PASSWORD_ENCRYPTION_KEY` | Length == 32 bytes |
| 7. VAPID keys | Auto-generate button (calls `webpush.generateVAPIDKeys()`) | Structural check — public key is a valid base64url-encoded P-256 point |
| 8. Admin account | Select from OIDC-discovered members OR enter the sub/preferred_username manually | Not empty |
| 9. Fastmail app password | App password for the primary calendar account | Test CalDAV `PROPFIND` to `https://caldav.fastmail.com/dav/principals/user/<email>/`; show pass/fail |
| 10. Confirm + save | Summary of all inputs | Writes config to DB/env; sets `setup_complete`; redirects to app |
**Validation UX:** Inline per-field — show a green checkmark or red error directly below the field as soon as the user leaves it (blur event) or clicks a test button. "Next" button is disabled until the current step passes validation. Show human-readable error messages: "Could not reach the OIDC issuer — is Authelia running?" not "fetch failed: ERR_CONNECTION_REFUSED".
@@ -219,6 +220,7 @@ Features that appear reasonable but are wrong for a 2-person self-hosted househo
**Current latency:** Up to ~15 seconds (outbox poll interval) + 5-minute CalDAV read-back poller.
**Approach:**
1. The existing optimistic-202 response already updates the React Query cache immediately on save (latency = 0 for the UI). The gap is the CalDAV write-back actually landing, which matters for reminders and cross-device visibility.
2. On INSERT to the `outbox` table, emit an event (in-process EventEmitter or a Redis pub/sub message if the outbox worker is in a separate process) that triggers an immediate drain attempt.
3. On successful CalDAV PUT, emit an SSE `calendar-updated` event to connected clients so React Query invalidates the calendar cache and re-fetches. The re-fetch is the "write landed" confirmation.
@@ -233,6 +235,7 @@ Features that appear reasonable but are wrong for a 2-person self-hosted househo
**Trigger:** On `pull_request` to `main`.
**Steps:**
1. Checkout
2. pnpm install (cached)
3. TypeScript typecheck — `pnpm -r tsc --noEmit` (both `apps/api` and `apps/pwa`)
@@ -251,6 +254,7 @@ Features that appear reasonable but are wrong for a 2-person self-hosted househo
**What it is:** A reusable Playwright configuration profile using `devices['iPhone 15']` (or equivalent) with stored auth state (`DEV_AUTH_BYPASS=true` or a saved `storageState` JSON from a prior login), usable by the assistant via `playwright-cli` without re-authenticating on every run.
**What it covers:**
- Mobile viewport layout (bottom nav, drawer sizing, touch targets >= 44px)
- Calendar view rendering at iPhone screen width
- Event form usability on mobile (reminder selector visible, not clipped)
@@ -266,15 +270,15 @@ Features that appear reasonable but are wrong for a 2-person self-hosted househo
## Competitor / Prior Art Reference
| Feature | Apple Calendar | Google Calendar | Fastmail native | Nextcloud | This Product (v1.1 target) |
|---------|---------------|-----------------|-----------------|-----------|---------------------------|
| Reminder presets | None / 5m / 15m / 30m / 1h / 2h / 1d / 2d / 1w | None / 5m / 10m / 15m / 30m / 1h / 2h / 1d / 2d | None / 15m / 1h / 1d | N/A | None / 5m / 10m / 15m / 30m / 1h / 2h / 1d / 2d |
| Multiple alarms | Yes (up to 5) | Yes (up to 5) | Yes | N/A | V1.1: 1; stretch: 2 |
| All-day reminder time | 9 AM on alert day | 11:50 PM night before (jarring) | Morning | N/A | 9 AM (Apple convention) |
| Admin credential mgmt | N/A | N/A | N/A | Yes (complex) | Minimal: 2 tasks only |
| Setup wizard | N/A | N/A | N/A | Yes (3-step minimal) | 10-step validated |
| Write-back latency | ~1s | ~1s | ~1s | Varies | Target ~1s (from ~15s) |
| CI | N/A | N/A | N/A | GitHub Actions | Gitea Actions |
| Feature | Apple Calendar | Google Calendar | Fastmail native | Nextcloud | This Product (v1.1 target) |
| --------------------- | ---------------------------------------------- | ----------------------------------------------- | -------------------- | -------------------- | ----------------------------------------------- |
| Reminder presets | None / 5m / 15m / 30m / 1h / 2h / 1d / 2d / 1w | None / 5m / 10m / 15m / 30m / 1h / 2h / 1d / 2d | None / 15m / 1h / 1d | N/A | None / 5m / 10m / 15m / 30m / 1h / 2h / 1d / 2d |
| Multiple alarms | Yes (up to 5) | Yes (up to 5) | Yes | N/A | V1.1: 1; stretch: 2 |
| All-day reminder time | 9 AM on alert day | 11:50 PM night before (jarring) | Morning | N/A | 9 AM (Apple convention) |
| Admin credential mgmt | N/A | N/A | N/A | Yes (complex) | Minimal: 2 tasks only |
| Setup wizard | N/A | N/A | N/A | Yes (3-step minimal) | 10-step validated |
| Write-back latency | ~1s | ~1s | ~1s | Varies | Target ~1s (from ~15s) |
| CI | N/A | N/A | N/A | GitHub Actions | Gitea Actions |
---
@@ -297,5 +301,6 @@ Features that appear reasonable but are wrong for a 2-person self-hosted househo
- [Playwright emulation docs](https://playwright.dev/docs/emulation)
---
*Feature research for: FamilySync v1.1 — Operability & Polish*
*Researched: 2026-06-10*
_Feature research for: FamilySync v1.1 — Operability & Polish_
_Researched: 2026-06-10_
+77 -58
View File
@@ -35,6 +35,7 @@ The inverse is equally dangerous: if the editor sends `reminderMinutes: 0` (mean
On the update path in `outboxWorker.ts`, before calling `buildVeventString`, parse `rawVevent` with ical.js and extract all existing `VALARM` sub-components. Merge them: if the outbox payload carries an explicit reminder choice (the new `reminderMinutes` field), replace all extracted VALARMs with the new one (or with none if `reminderMinutes: null`). If the payload carries no reminder field (no explicit user change), carry the extracted VALARMs forward into `buildVeventString` as a `valarms` parameter. This mirrors the WR-01 RRULE-preserve pattern exactly. Extend the `outboxPayloadSchema` with an optional `reminderMinutes: z.number().int().min(0).nullable().optional()` field so the absence of the key is distinguishable from an explicit "no reminder."
**Warning signs:**
- Reminders set in the Fastmail native app disappear after editing the event in FamilySync.
- A shared event with a reminder shows the reminder field as empty after an FamilySync round-trip.
- `rawVevent` in `calendar_events` has `BEGIN:VALARM` but the PUT payload does not.
@@ -48,6 +49,7 @@ Per-event reminders phase (VALARM authoring). The VALARM-preserve logic must lan
**What goes wrong:**
RFC 5545 §3.8.6.3 defines two legal TRIGGER value types for VALARM:
- `DURATION` (default): `TRIGGER:-PT15M` — fires 15 minutes before DTSTART.
- `DATE-TIME`: `TRIGGER;VALUE=DATE-TIME:20260610T120000Z` — fires at an absolute UTC instant.
@@ -64,6 +66,7 @@ Build the TRIGGER using `ICAL.Duration.fromSeconds(-reminderMinutes * 60)` and s
Add a unit test: build a VALARM with `reminderMinutes: 15`, serialize to ICS, parse back with ical.js, and assert the TRIGGER DURATION value is `-PT15M` with no VALUE parameter other than DURATION (which is the default and is usually omitted).
**Warning signs:**
- ICS output contains `TRIGGER;VALUE=TEXT:-PT15M`.
- Reminders appear in the FamilySync UI but never fire on the device.
- Apple Calendar / Fastmail app shows the event with no alarm after an FamilySync edit.
@@ -87,6 +90,7 @@ In the event form UI: disable or hide the reminder selector when `allDay: true`.
In the scheduler, when v1.1 generalizes the lead time: keep the `WHERE allDay=false` guard in the SQL query regardless of how VALARM data is stored. Do not "fix" this by removing the guard when you extend VALARM support.
**Warning signs:**
- All-day event with reminder set produces an ICS with a VALARM on a DATE-typed DTSTART.
- User reports reminder not firing for an all-day birthday event.
- Reminder field enabled in the UI for all-day events.
@@ -116,6 +120,7 @@ For the variable-window query: instead of scanning a fixed `(now, now+16min]` wi
Add an integration test for the scheduler that covers: (a) event with a 30-minute lead fires at T-30, (b) event rescheduled earlier after the first fire fires again for the new time.
**Warning signs:**
- Events with a long reminder lead never fire.
- Rescheduled event reminder does not fire after the reschedule.
- Scheduler dedup map grows without bound (no uid-dtstart pair is ever pruned because the dtstart moved out from under the map entry).
@@ -147,6 +152,7 @@ A more dangerous double-drain scenario arises if the event-driven trigger is imp
Wrap the event-driven call in the same caught wrapper. More importantly: do not call `runOutboxDrain()` directly from the pub/sub subscriber. Instead, call a `triggerDrain()` helper that sets `isDraining = true` synchronously before the first await, or simply lets the setInterval do the work and uses the pub/sub message only to shorten the next wait (e.g., trigger a single immediate `runOutboxDrain()` call from within the setInterval handler if a "pending" flag is set, keeping all drain calls single-threaded through the interval). The cleanest approach: keep one drain path (the setInterval), but when an enqueue event arrives, set a `drainRequested` flag; the next setInterval tick checks the flag and drains immediately instead of waiting the full 15s.
**Warning signs:**
- Duplicate CalDAV PUTs for the same event visible in Fastmail logs.
- Two identical events appearing briefly after an edit.
- 412 conflict errors on the second of two simultaneous drain calls (the first PUT succeeded, the second uses an outdated etag).
@@ -174,6 +180,7 @@ Always enqueue the CREATE row before the DELETE row in the HTTP handler, matchin
Do not trigger the event-driven drain between the two enqueue inserts. If the trigger is a direct call, wrap both inserts in a single DB transaction and trigger the drain only after the transaction commits. If the trigger is Redis pub/sub, publish after both inserts.
**Warning signs:**
- Edit-as-move operations produce a "calendar object not found" error from Fastmail (delete reached Fastmail before the create).
- Events occasionally disappear after an edit and reappear after the next poller sync cycle.
- CR-04 deferral log messages (`Deferring delete row...`) appearing frequently for move operations.
@@ -199,6 +206,7 @@ In the `@hono/zod-validator` middleware for the app-password body schema, always
For test coverage: write a unit test that asserts the route returns `400` with no `value` field in the response when given an invalid password. Do not assert on the specific Zod error message.
**Warning signs:**
- App password appears in any log output or API response body.
- The Zod error response for the settings route includes a `received` or `message` field containing password-like strings.
@@ -225,6 +233,7 @@ Alternatively, use a DB-stored `setup_completed_at` timestamp in a `settings` ta
Never accept the `APP_PASSWORD_ENCRYPTION_KEY` value via the API. The wizard should validate that the env is already set (by attempting a test encrypt/decrypt), not collect the key. The key stays in the env/Docker secrets layer.
**Warning signs:**
- Setup endpoint returns 200 after the app is already configured.
- Curl to `/api/setup/...` with no auth cookie returns a non-401/423 response.
- Setup route has no test covering the "already set up" scenario.
@@ -248,6 +257,7 @@ Hono's middleware scoping is based on route prefix at mount time, not at route d
Apply the admin middleware inside `adminRouter` itself (`.use('*', adminGuard)`), not only in the parent app. Write an integration test that calls a settings route as a non-admin authenticated user and asserts 403. Do not rely on the parent app's middleware order for sub-app security.
**Warning signs:**
- Any authenticated user can reach `/api/admin/...` routes without an admin check in the response.
- The admin middleware is defined in `index.ts` but the admin routes are in a separate `adminRouter` with no internal middleware.
@@ -260,6 +270,7 @@ Admin Settings phase. Integration test for 403 on non-admin access is the accept
**What goes wrong:**
The setup wizard collects VAPID keypair and validates the Fastmail app password. A tempting shortcut: store the VAPID keys in the `settings` DB table for easy retrieval later. The problem: `VAPID_PRIVATE_KEY` is a signing key — equivalent to a private TLS key. Storing it in the DB means it is:
- Accessible to anyone with DB read access (including `SELECT *` from a misconfigured tool or a Drizzle Studio session left open).
- Included in DB backups, which may be stored less securely.
- Returned by any accidental DB dump to logs.
@@ -275,6 +286,7 @@ Hard rule: `VAPID_PRIVATE_KEY` and `APP_PASSWORD_ENCRYPTION_KEY` never touch the
The wizard's "check env" validation path: call `encryptPassword('test')` — if it throws, `APP_PASSWORD_ENCRYPTION_KEY` is missing or malformed. Call `webpush.setVapidDetails(...)` and catch throws. Never read the key values out of `process.env` into a response body.
**Warning signs:**
- DB schema has a `vapid_private_key` column.
- Any API response that includes `VAPID_PRIVATE_KEY` or `APP_PASSWORD_ENCRYPTION_KEY` values.
- Wizard stores all config to DB and reads it back on next startup instead of requiring env vars.
@@ -296,6 +308,7 @@ The Docker `HEALTHCHECK` for MariaDB using `mysqladmin ping` returns true as soo
Add a `wait-for-it` or `until mysqladmin ping --silent; do sleep 1; done` step in the CI workflow after the service is declared healthy, before running any DB command. Or use a longer `healthcheck.start_period` in the service definition (e.g., 30 seconds). Also: set `MARIADB_ROOT_PASSWORD`, `MARIADB_DATABASE`, `MARIADB_USER`, `MARIADB_PASSWORD` in the service env and use those same credentials in the integration test step — do not assume the root user is reachable from the test runner without a password.
**Warning signs:**
- CI passes on re-run but fails on first run of a PR (timing-dependent).
- `ECONNREFUSED` or `Error: connect ECONNREFUSED 127.0.0.1:3306` in CI logs.
- Tests that pass locally with a warm MariaDB fail in CI cold-start.
@@ -321,6 +334,7 @@ In the first CI plan, write a minimal "hello world" workflow that only checks `n
For Docker image build/publish: verify the runner has Docker daemon access. On Unraid self-hosted runners, Docker may require `--privileged` or specific socket mounts that need runner configuration.
**Warning signs:**
- `pnpm: command not found` in CI output.
- `node` resolves to a version older than 22 in CI but not locally.
- `actions/setup-node` step shows as skipped or errored in the Gitea Actions UI.
@@ -342,6 +356,7 @@ Docker CLI login via `-p` flag is the most common example in docs. GitHub Action
Use `docker login --password-stdin` with the token piped via stdin rather than a command-line argument: `echo "${{ secrets.REGISTRY_TOKEN }}" | docker login -u "${{ secrets.REGISTRY_USER }}" --password-stdin registry.example.com`. Register all credentials as Gitea repository secrets, not as environment variables in the workflow YAML. Verify the Gitea version supports secret masking in the Actions log (Gitea ≥ 1.19 for Actions support; secret masking behavior varies by version).
**Warning signs:**
- Registry token or password visible as plaintext in the Gitea Actions job log.
- `docker login` command line includes `-p <token>` in the log output.
- `secrets.REGISTRY_TOKEN` is undefined in the workflow (token was set as env var, not secret).
@@ -363,6 +378,7 @@ Playwright storage state is file-based and not automatically refreshed. Develope
Do not use a static stored storage state for tests that run against the production OIDC path. Instead, implement a programmatic login helper that runs the OIDC authorization code flow at the start of each test session (or once per test run) and stores the resulting session. For the `DEV_AUTH_BYPASS` dev environment, the harness sets `DEV_AUTH_BYPASS=true` and skips the storage state entirely. The mobile viewport emulation does not require real OIDC — use `DEV_AUTH_BYPASS` for the automated harness; keep real OIDC tests as manual/human gates.
**Warning signs:**
- Playwright runs fail with `Expected 200 OK but got 302 Found` after leaving the storage state untouched for more than one day.
- Tests that exercise `/api/*` routes return HTML (the Authelia login page) instead of JSON.
- The same test suite passes reliably in `DEV_AUTH_BYPASS=true` mode but fails intermittently in production-auth mode.
@@ -376,6 +392,7 @@ Mobile-browser testing phase. The storage state strategy must be decided before
**What goes wrong:**
The installed Vite PWA service worker (`sw.js`) is registered in the browser when the PWA is visited. Playwright's Chromium instance can load and activate the service worker from a previous test run (persisted in the browser's profile directory). On subsequent test runs, the service worker intercepts API calls — potentially returning cached responses from the previous run rather than making network requests to the test server. This causes:
- API requests returning stale 200 responses when the test server is not running.
- `queryClient.invalidateQueries` not triggering new network requests (SW returns cached response).
- Tests that verify freshly-created data returning old data.
@@ -387,6 +404,7 @@ Workbox's cache-first strategy for static assets and stale-while-revalidate for
Use `browserContext.clearCookies()` and `browserContext.clearPermissions()` in the test setup, but also explicitly unregister service workers: `await page.evaluate(() => navigator.serviceWorker.getRegistrations().then(r => Promise.all(r.map(sw => sw.unregister()))))` before any navigation. Or launch Playwright with `serviceWorkers: 'block'` in the context options, which prevents the SW from intercepting requests entirely. For tests that specifically test offline/SW behavior, use a separate context without the block.
**Warning signs:**
- Network tab in Playwright traces shows `(ServiceWorker)` as the response source.
- Tests pass on a clean browser profile but fail on a profile that has visited the PWA before.
- API requests complete instantly with stale data in the Playwright trace.
@@ -398,45 +416,45 @@ Mobile-browser testing phase. The Playwright context setup must explicitly handl
## Technical Debt Patterns
| Shortcut | Immediate Benefit | Long-term Cost | When Acceptable |
|----------|-------------------|----------------|-----------------|
| Building VALARM on top of `buildVeventString` without the preserve-on-edit path | Faster to implement | Strips native-client alarms on every edit; user data loss | Never — preserve path must ship with VALARM authoring |
| uid-only dedup key in sentReminders when lead times become variable | No migration needed | Duplicate pushes or missed re-fires after reschedule | Never for production; acceptable in tests with a fixed lead |
| Calling `runOutboxDrain()` directly from event trigger instead of setting a flag | Simpler code | Bypasses `isDraining` atomicity, potential double-drain | Never — always funnel through the single setInterval-controlled path |
| Setup wizard that accepts `APP_PASSWORD_ENCRYPTION_KEY` via the API | Simpler UX for initial setup | Entire encryption model is broken | Never — key stays in env/secrets only |
| Static storage-state.json checked into the repo | Zero-effort Playwright auth | Tests fail silently after TTY expiry; potential credential leak | Never — programmatic refresh or DEV_AUTH_BYPASS only |
| `docker login -p $TOKEN` in CI command | Quick to write | Token appears in CI logs if secret not masked | Never — always use --password-stdin |
| No readiness wait for MariaDB service in CI | Simpler YAML | Flaky CI: timing-dependent ECONNREFUSED failures | Never — readiness wait is 3 lines and prevents ghost failures |
| Shortcut | Immediate Benefit | Long-term Cost | When Acceptable |
| -------------------------------------------------------------------------------- | ---------------------------- | --------------------------------------------------------------- | -------------------------------------------------------------------- |
| Building VALARM on top of `buildVeventString` without the preserve-on-edit path | Faster to implement | Strips native-client alarms on every edit; user data loss | Never — preserve path must ship with VALARM authoring |
| uid-only dedup key in sentReminders when lead times become variable | No migration needed | Duplicate pushes or missed re-fires after reschedule | Never for production; acceptable in tests with a fixed lead |
| Calling `runOutboxDrain()` directly from event trigger instead of setting a flag | Simpler code | Bypasses `isDraining` atomicity, potential double-drain | Never — always funnel through the single setInterval-controlled path |
| Setup wizard that accepts `APP_PASSWORD_ENCRYPTION_KEY` via the API | Simpler UX for initial setup | Entire encryption model is broken | Never — key stays in env/secrets only |
| Static storage-state.json checked into the repo | Zero-effort Playwright auth | Tests fail silently after TTY expiry; potential credential leak | Never — programmatic refresh or DEV_AUTH_BYPASS only |
| `docker login -p $TOKEN` in CI command | Quick to write | Token appears in CI logs if secret not masked | Never — always use --password-stdin |
| No readiness wait for MariaDB service in CI | Simpler YAML | Flaky CI: timing-dependent ECONNREFUSED failures | Never — readiness wait is 3 lines and prevents ghost failures |
---
## Integration Gotchas
| Integration | Common Mistake | Correct Approach |
|-------------|----------------|------------------|
| ical.js VALARM | Using `addPropertyWithValue('trigger', '-PT15M')` (string) | Build `ICAL.Duration.fromSeconds(-N*60)` and use the Duration object as the property value |
| ical.js VALARM | Not round-tripping preserved VALARMs through ical.js parse→serialize | Parse the sub-component from rawVevent and re-add via ical.js API; do not insert raw text |
| outboxWorker VALARM | Rebuilding VEVENT from scratch drops native-client VALARMs on update | Extend buildVeventString to accept a `valarms` parameter; populate from rawVevent extract on update path |
| reminderScheduler dedup | uid-only Map key breaks when per-event leads vary | Key on `uid + ':' + dtstartMs`; prune by dtstartMs |
| event-driven drain | Calling `runOutboxDrain()` from pub/sub subscriber before `isDraining` is set | Use a single drain path via `drainRequested` flag checked in the setInterval callback |
| Gitea Actions | Using GitHub Actions-specific action IDs | Probe the runner first; use Gitea-compatible alternatives or install tools explicitly |
| Gitea Actions MariaDB | Relying on container health == connection ready | Add explicit `mysqladmin ping` retry loop after healthcheck passes |
| Playwright mobile harness | Static storage-state.json with expiring session cookie | Use `DEV_AUTH_BYPASS=true` for automated harness; programmatic OIDC login for real-auth tests |
| Playwright + Vite PWA | Service worker from previous run intercepting requests | Set `serviceWorkers: 'block'` or unregister SWs explicitly in test context setup |
| Setup wizard | Accepting `APP_PASSWORD_ENCRYPTION_KEY` via the POST body | Validate the env is present by performing a test operation; never accept the key value over the network |
| Admin settings route | Zod error passthrough leaking app-password input | Custom `hook` in zod-validator: return generic 400, never the Zod error object |
| Integration | Common Mistake | Correct Approach |
| ------------------------- | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| ical.js VALARM | Using `addPropertyWithValue('trigger', '-PT15M')` (string) | Build `ICAL.Duration.fromSeconds(-N*60)` and use the Duration object as the property value |
| ical.js VALARM | Not round-tripping preserved VALARMs through ical.js parse→serialize | Parse the sub-component from rawVevent and re-add via ical.js API; do not insert raw text |
| outboxWorker VALARM | Rebuilding VEVENT from scratch drops native-client VALARMs on update | Extend buildVeventString to accept a `valarms` parameter; populate from rawVevent extract on update path |
| reminderScheduler dedup | uid-only Map key breaks when per-event leads vary | Key on `uid + ':' + dtstartMs`; prune by dtstartMs |
| event-driven drain | Calling `runOutboxDrain()` from pub/sub subscriber before `isDraining` is set | Use a single drain path via `drainRequested` flag checked in the setInterval callback |
| Gitea Actions | Using GitHub Actions-specific action IDs | Probe the runner first; use Gitea-compatible alternatives or install tools explicitly |
| Gitea Actions MariaDB | Relying on container health == connection ready | Add explicit `mysqladmin ping` retry loop after healthcheck passes |
| Playwright mobile harness | Static storage-state.json with expiring session cookie | Use `DEV_AUTH_BYPASS=true` for automated harness; programmatic OIDC login for real-auth tests |
| Playwright + Vite PWA | Service worker from previous run intercepting requests | Set `serviceWorkers: 'block'` or unregister SWs explicitly in test context setup |
| Setup wizard | Accepting `APP_PASSWORD_ENCRYPTION_KEY` via the POST body | Validate the env is present by performing a test operation; never accept the key value over the network |
| Admin settings route | Zod error passthrough leaking app-password input | Custom `hook` in zod-validator: return generic 400, never the Zod error object |
---
## Security Mistakes
| Mistake | Risk | Prevention |
|---------|------|------------|
| Admin route not protected inside adminRouter (only in parent app) | Any authenticated member can call admin endpoints | Apply guard middleware inside the sub-router, not only in the parent app mount |
| Setup endpoint lacks "already-set-up" guard | Post-setup endpoint rewrites credentials without auth | Check `member_credentials` existence + VAPID env on every setup route invocation; return 423 if already configured |
| VAPID_PRIVATE_KEY stored in DB | Private signing key accessible to DB-level access | VAPID private key in env/secrets only; DB stores public key and subject only |
| App-password in Zod error response | Plaintext credential in HTTP response and server logs | Custom Zod hook for all routes that accept credential input |
| `docker login -p` in CI YAML | Registry token in CI logs | `--password-stdin` only; token as Gitea secret, not YAML env var |
| Mistake | Risk | Prevention |
| ----------------------------------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| Admin route not protected inside adminRouter (only in parent app) | Any authenticated member can call admin endpoints | Apply guard middleware inside the sub-router, not only in the parent app mount |
| Setup endpoint lacks "already-set-up" guard | Post-setup endpoint rewrites credentials without auth | Check `member_credentials` existence + VAPID env on every setup route invocation; return 423 if already configured |
| VAPID_PRIVATE_KEY stored in DB | Private signing key accessible to DB-level access | VAPID private key in env/secrets only; DB stores public key and subject only |
| App-password in Zod error response | Plaintext credential in HTTP response and server logs | Custom Zod hook for all routes that accept credential input |
| `docker login -p` in CI YAML | Registry token in CI logs | `--password-stdin` only; token as Gitea secret, not YAML env var |
---
@@ -457,38 +475,38 @@ Mobile-browser testing phase. The Playwright context setup must explicitly handl
## Recovery Strategies
| Pitfall | Recovery Cost | Recovery Steps |
|---------|---------------|----------------|
| VALARM strips native alarms on edit | MEDIUM | Add valarms preserve path to buildVeventString + outboxWorker update branch; no migration needed; existing rawVevent data is authoritative |
| TRIGGER VALUE=TEXT bug | LOW | Fix Duration construction in buildVeventString; no data migration (rawVevent already has correct alarms from server) |
| uid-only dedup causing duplicate push | LOW | Change Map key to uid:dtstartMs; restart clears in-memory state; no DB change |
| Double-drain from concurrent triggers | MEDIUM | Refactor event-driven trigger to drainRequested flag; requires load testing to confirm no more duplicate PUTs |
| Admin route bypassed (no inner guard) | LOW | Add `.use('*', adminGuard)` inside adminRouter; deploy |
| VAPID private key in DB | HIGH | Rotate VAPID keypair; clear all push subscriptions (all devices must re-subscribe); remove DB column via migration |
| CI flaky MariaDB race | LOW | Add readiness wait loop to workflow YAML; re-run |
| Playwright storage state stale | LOW | Switch to DEV_AUTH_BYPASS mode for automated tests; remove static state file |
| Pitfall | Recovery Cost | Recovery Steps |
| ------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| VALARM strips native alarms on edit | MEDIUM | Add valarms preserve path to buildVeventString + outboxWorker update branch; no migration needed; existing rawVevent data is authoritative |
| TRIGGER VALUE=TEXT bug | LOW | Fix Duration construction in buildVeventString; no data migration (rawVevent already has correct alarms from server) |
| uid-only dedup causing duplicate push | LOW | Change Map key to uid:dtstartMs; restart clears in-memory state; no DB change |
| Double-drain from concurrent triggers | MEDIUM | Refactor event-driven trigger to drainRequested flag; requires load testing to confirm no more duplicate PUTs |
| Admin route bypassed (no inner guard) | LOW | Add `.use('*', adminGuard)` inside adminRouter; deploy |
| VAPID private key in DB | HIGH | Rotate VAPID keypair; clear all push subscriptions (all devices must re-subscribe); remove DB column via migration |
| CI flaky MariaDB race | LOW | Add readiness wait loop to workflow YAML; re-run |
| Playwright storage state stale | LOW | Switch to DEV_AUTH_BYPASS mode for automated tests; remove static state file |
---
## Pitfall-to-Phase Mapping
| Pitfall | Prevention Phase | Verification |
|---------|------------------|--------------|
| VALARM strips native alarms on edit | Per-event reminders (VALARM authoring) | Integration test: create event via native client with alarm, edit via FamilySync, verify PUT payload contains original VALARM |
| TRIGGER VALUE=TEXT serialization | Per-event reminders (VALARM authoring) | Unit test: serialize VALARM, parse back, assert no VALUE=TEXT |
| All-day event VALARM silently no-ops | Per-event reminders (VALARM authoring) | UI test: all-day event form has no reminder field or field is disabled |
| Variable-lead dedup produces duplicate push | Per-event reminders (scheduler generalization) | Unit test: fire reminder, reschedule event earlier, fire again — assert two pushes sent |
| Double-drain from concurrent event-driven trigger | Event-driven outbox drain | Load test: enqueue 10 rows rapidly, assert each CalDAV PUT issued exactly once |
| Event-driven drain breaks create-before-delete | Event-driven outbox drain | Integration test: edit-as-move under rapid enqueue; original event not deleted before new one created |
| Admin app-password echoed in error | Admin Settings | Unit test: POST invalid password to settings route; assert response has no credential value |
| Unauthenticated setup endpoint stays live | Setup wizard | Integration test: POST to setup endpoint after first-run completes; assert 423 |
| Admin role check missing inside sub-router | Admin Settings | Integration test: non-admin authenticated user hits admin route; assert 403 |
| VAPID key stored in DB | Setup wizard | Schema review before migration is written; CI lint check for column names containing `private_key` |
| Gitea CI MariaDB readiness race | Gitea CI | CI log audit: readiness loop appears before any `drizzle-kit migrate` invocation |
| Gitea runner missing Node 22 / pnpm | Gitea CI | First CI job: node/pnpm version probe step before any install or test |
| Docker registry token in CI logs | Gitea CI | CI log audit: no plaintext token visible; all registry credentials use --password-stdin |
| Playwright storage state stale | Mobile-browser testing | Test suite passes on day 2 without recapturing storage state (DEV_AUTH_BYPASS mode eliminates TTL) |
| Production service worker intercepts Playwright | Mobile-browser testing | Playwright context uses `serviceWorkers: 'block'`; verified in trace that no responses are SW-sourced |
| Pitfall | Prevention Phase | Verification |
| ------------------------------------------------- | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| VALARM strips native alarms on edit | Per-event reminders (VALARM authoring) | Integration test: create event via native client with alarm, edit via FamilySync, verify PUT payload contains original VALARM |
| TRIGGER VALUE=TEXT serialization | Per-event reminders (VALARM authoring) | Unit test: serialize VALARM, parse back, assert no VALUE=TEXT |
| All-day event VALARM silently no-ops | Per-event reminders (VALARM authoring) | UI test: all-day event form has no reminder field or field is disabled |
| Variable-lead dedup produces duplicate push | Per-event reminders (scheduler generalization) | Unit test: fire reminder, reschedule event earlier, fire again — assert two pushes sent |
| Double-drain from concurrent event-driven trigger | Event-driven outbox drain | Load test: enqueue 10 rows rapidly, assert each CalDAV PUT issued exactly once |
| Event-driven drain breaks create-before-delete | Event-driven outbox drain | Integration test: edit-as-move under rapid enqueue; original event not deleted before new one created |
| Admin app-password echoed in error | Admin Settings | Unit test: POST invalid password to settings route; assert response has no credential value |
| Unauthenticated setup endpoint stays live | Setup wizard | Integration test: POST to setup endpoint after first-run completes; assert 423 |
| Admin role check missing inside sub-router | Admin Settings | Integration test: non-admin authenticated user hits admin route; assert 403 |
| VAPID key stored in DB | Setup wizard | Schema review before migration is written; CI lint check for column names containing `private_key` |
| Gitea CI MariaDB readiness race | Gitea CI | CI log audit: readiness loop appears before any `drizzle-kit migrate` invocation |
| Gitea runner missing Node 22 / pnpm | Gitea CI | First CI job: node/pnpm version probe step before any install or test |
| Docker registry token in CI logs | Gitea CI | CI log audit: no plaintext token visible; all registry credentials use --password-stdin |
| Playwright storage state stale | Mobile-browser testing | Test suite passes on day 2 without recapturing storage state (DEV_AUTH_BYPASS mode eliminates TTL) |
| Production service worker intercepts Playwright | Mobile-browser testing | Playwright context uses `serviceWorkers: 'block'`; verified in trace that no responses are SW-sourced |
---
@@ -504,5 +522,6 @@ Mobile-browser testing phase. The Playwright context setup must explicitly handl
- Playwright docs — `browserContext.serviceWorkers`, `storageState`, context lifecycle
---
*Pitfalls research for: FamilySync v1.1 Operability & Polish*
*Researched: 2026-06-10*
_Pitfalls research for: FamilySync v1.1 Operability & Polish_
_Researched: 2026-06-10_
+89 -83
View File
@@ -12,15 +12,15 @@ This section covers ONLY what is new for v1.1. The rest of the file (below) docu
### What needs NO new dependency
| Feature | Existing tool that covers it | Why no addition needed |
|---------|------------------------------|------------------------|
| Per-event reminders (VALARM) | `ical.js` + `tsdav` + existing write path | VALARM is a VCALENDAR component; ical.js parses/emits it; tsdav handles the PUT. No new library. |
| Outbox drain event-driven wake | `ioredis` pub/sub (already in stack) | Publish a `caldav:drain` event on Redis after a write; outbox worker subscribes and drains immediately. Zero new deps. |
| Admin Settings UI (app passwords + shared calendar) | Existing Drizzle schema + AES-256-GCM crypto (already in `apps/api`) | Role-gated Hono routes + React form. Schema already has the tables. |
| Setup wizard — DB connectivity probe | `mysql2` (already in stack) | Attempt a `mysql2` connect with the env-supplied credentials; resolve/reject gives pass/fail. |
| Setup wizard — VAPID key validation | `web-push` + Node.js built-in `crypto` (already in stack) | `Buffer.from(key, 'base64url').length === 32` for the private key; `web-push.generateVAPIDKeys()` for a fresh keypair; no extra library. |
| Setup wizard — OIDC discovery probe | Node.js 22 built-in `fetch` | `fetch(issuer + '/.well-known/openid-configuration')` and check for `200` + `authorization_endpoint` field. Native fetch in Node 22; zero extra library. |
| Setup wizard — env-var presence checks | `zod` (already in stack) | A `z.object({...}).safeParse(process.env)` at startup is the entire validation. Already used for request body validation. |
| Feature | Existing tool that covers it | Why no addition needed |
| --------------------------------------------------- | -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Per-event reminders (VALARM) | `ical.js` + `tsdav` + existing write path | VALARM is a VCALENDAR component; ical.js parses/emits it; tsdav handles the PUT. No new library. |
| Outbox drain event-driven wake | `ioredis` pub/sub (already in stack) | Publish a `caldav:drain` event on Redis after a write; outbox worker subscribes and drains immediately. Zero new deps. |
| Admin Settings UI (app passwords + shared calendar) | Existing Drizzle schema + AES-256-GCM crypto (already in `apps/api`) | Role-gated Hono routes + React form. Schema already has the tables. |
| Setup wizard — DB connectivity probe | `mysql2` (already in stack) | Attempt a `mysql2` connect with the env-supplied credentials; resolve/reject gives pass/fail. |
| Setup wizard — VAPID key validation | `web-push` + Node.js built-in `crypto` (already in stack) | `Buffer.from(key, 'base64url').length === 32` for the private key; `web-push.generateVAPIDKeys()` for a fresh keypair; no extra library. |
| Setup wizard — OIDC discovery probe | Node.js 22 built-in `fetch` | `fetch(issuer + '/.well-known/openid-configuration')` and check for `200` + `authorization_endpoint` field. Native fetch in Node 22; zero extra library. |
| Setup wizard — env-var presence checks | `zod` (already in stack) | A `z.object({...}).safeParse(process.env)` at startup is the entire validation. Already used for request body validation. |
### What IS new for v1.1
@@ -37,6 +37,7 @@ This section covers ONLY what is new for v1.1. The rest of the file (below) docu
**Install scope:** `devDependencies` in `apps/pwa` only (not the monorepo root; only the PWA workspace needs browser tests).
**Why this and not playwright-cli alone:**
- `playwright-cli` (the global binary) does not support `storageState` file save/restore — the mechanism required to inject an Authelia session into a test context without re-running the full OIDC redirect flow on every test run.
- `@playwright/test` provides `devices` registry (iPhone 15 Pro, Pixel 5, etc.) which sets `viewport`, `userAgent`, `isMobile`, `hasTouch` together as a named preset.
- `@playwright/test` is the only path to a `playwright.config.ts` that defines a `setup` project (do login once, write `storageState` to `.auth/user.json`) and a `mobile` project that consumes it — the pattern needed for an authenticated, mobile-emulated CI run against the DEV_AUTH_BYPASS entry point.
@@ -136,7 +137,7 @@ jobs:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ${{ vars.GITEA_REGISTRY }} # e.g. git.bergerhouse.ca
registry: ${{ vars.GITEA_REGISTRY }} # e.g. git.bergerhouse.ca
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- uses: docker/build-push-action@v5
@@ -169,42 +170,42 @@ jobs:
### 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 |
| 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 |
| 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 |
| **@playwright/test** | **v1.1 NEW — Mobile PWA test harness** | **devDependency in apps/pwa only; 1.60.0** |
| 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 |
| **@playwright/test** | **v1.1 NEW — Mobile PWA test harness** | **devDependency in apps/pwa only; 1.60.0** |
---
@@ -266,6 +267,7 @@ Fastmail's multi-user calendar sharing is documented only for users within the s
**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`
@@ -274,6 +276,7 @@ Hono is the right size for this app. Express is fine but has no TypeScript-nativ
**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)
@@ -301,14 +304,14 @@ Standard for React PWAs. `vite-plugin-pwa` configures the Web App Manifest and i
**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 |
| 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` |
| 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.
@@ -323,12 +326,14 @@ Standard for React PWAs. `vite-plugin-pwa` configures the Web App Manifest and i
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`
@@ -347,6 +352,7 @@ Authelia's own integration docs show this exact pattern for Express.js (`express
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
@@ -358,50 +364,50 @@ Redis (`ioredis`) is only needed if multiple Node containers run behind a load b
## 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 |
| @playwright/test | playwright-cli alone | playwright-cli lacks storageState save/restore and device presets needed for CI; both coexist |
| PAT for Gitea registry | secrets.GITEA_TOKEN / built-in token | Gitea does not inject a built-in token with container-registry push scope; PAT required |
| 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 |
| @playwright/test | playwright-cli alone | playwright-cli lacks storageState save/restore and device presets needed for CI; both coexist |
| PAT for Gitea registry | secrets.GITEA_TOKEN / built-in token | Gitea does not inject a built-in token with container-registry push scope; PAT required |
---
## 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) |
| `mysqladmin ping` health check with MariaDB 11 | mysqladmin not shipped in mariadb:11 image; silently blocks CI | `healthcheck.sh --connect --innodb_initialized` |
| `runs-on: ubuntu-latest` on Gitea self-hosted runner | Label only resolves on GitHub's hosted infrastructure | `runs-on: self-hosted` (or the runner's registered label) |
| Any validation library for setup wizard | zod + mysql2 + web-push + Node 22 fetch cover all checks natively | Use existing stack |
| 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) |
| `mysqladmin ping` health check with MariaDB 11 | mysqladmin not shipped in mariadb:11 image; silently blocks CI | `healthcheck.sh --connect --innodb_initialized` |
| `runs-on: ubuntu-latest` on Gitea self-hosted runner | Label only resolves on GitHub's hosted infrastructure | `runs-on: self-hosted` (or the runner's registered label) |
| Any validation library for setup wizard | zod + mysql2 + web-push + Node 22 fetch cover all checks natively | Use existing stack |
---
## 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 |
| @playwright/test@1.60.x | Node.js 18+ | Install Chromium only in CI (`npx playwright install --with-deps chromium`) |
| mariadb:11 service container | GitHub/Gitea Actions | Health check must use `healthcheck.sh`; `mysqladmin` removed in 11.x |
| 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 |
| @playwright/test@1.60.x | Node.js 18+ | Install Chromium only in CI (`npx playwright install --with-deps chromium`) |
| mariadb:11 service container | GitHub/Gitea Actions | Health check must use `healthcheck.sh`; `mysqladmin` removed in 11.x |
---
@@ -449,5 +455,5 @@ Redis (`ioredis`) is only needed if multiple Node containers run behind a load b
---
*Stack research for: FamilySync — self-hosted family calendar + shared-lists PWA on Fastmail*
*Researched: 2026-06-03 (v1.0 baseline) / 2026-06-10 (v1.1 Operability & Polish additions)*
_Stack research for: FamilySync — self-hosted family calendar + shared-lists PWA on Fastmail_
_Researched: 2026-06-03 (v1.0 baseline) / 2026-06-10 (v1.1 Operability & Polish additions)_
+11 -7
View File
@@ -20,6 +20,7 @@ Three preservation rules are non-negotiable and drive the design: VALARM authori
No stack change. One new dev dependency; everything else reuses v1.0.
**Core additions:**
- **`@playwright/test`** (dev, `apps/pwa`): the global `playwright-cli` binary is interactive tooling and exposes no `storageState`/`devices` presets — `@playwright/test` is required for CI spec files doing authenticated, device-emulated runs. The two coexist. Auth via the existing `DEV_AUTH_BYPASS` avoids mocking Authelia.
- **Gitea Actions workflows** (`.gitea/workflows/*.yml`, no npm packages): GitHub-Actions-compatible syntax but `runs-on: self-hosted`; job image `catthehacker/ubuntu:act-latest`; MariaDB service container `mariadb:11` with `healthcheck.sh --connect --innodb_initialized` (NOT `mysqladmin ping` — removed in MariaDB 11); Docker push via `docker/login-action@v3` + `docker/build-push-action@v5` needs a Gitea PAT with `write:package` scope (no built-in token has registry push rights).
- **Setup wizard validation — zero new deps:** env presence via `zod.safeParse`, DB via `mysql2` connect, VAPID via `Buffer.from(key,'base64url').length === 32`, OIDC via native `fetch('/.well-known/openid-configuration')`.
@@ -27,12 +28,14 @@ No stack change. One new dev dependency; everything else reuses v1.0.
### Expected Features
**Must have (table stakes):**
- Per-event reminder selector with preset offsets (None / 5m / 10m / 15m / 30m / 1h / 2h / 1d / 2d); **"None" is the default** (no VALARM, no push). All-day events fire at 9 AM on the alert day (Apple convention). Existing VALARMs round-trip — never silently stripped.
- Admin Settings scoped to exactly two tasks: rotate/re-enter a member's app password (with inline CalDAV test) and toggle `calendars.is_shared`. Single `users.is_admin` boolean gate.
- Setup wizard: validated first-run steps (DB, app URL, OIDC, session secret auto-gen, encryption key auto-gen, VAPID auto-gen + structural check, admin account, Fastmail app password CalDAV PROPFIND test). Inline per-field validation; Next disabled until step passes.
- Faster write-back: target < 2s perceived; trigger an immediate drain on enqueue, keep the interval as fallback.
**Should have (competitive / differentiator):**
- Gitea CI PR gate + on-merge Docker publish.
- Mobile Playwright harness (`devices['iPhone 15']`, stored auth via `DEV_AUTH_BYPASS`).
- Multiple alarms per event (2× VALARM) — stretch, defer to v1.2.
@@ -44,6 +47,7 @@ No stack change. One new dev dependency; everything else reuses v1.0.
Findings grounded in the actual v1.0 codebase. Integration points (real paths):
**Major components:**
1. **DB migration**`users.is_admin BOOLEAN DEFAULT 0`, `calendar_events.reminder_lead_minutes INT NULL`, an `app_config`/setup-state table. Foundation; blocks the role-gated and reminder work. (generate+migrate, never `push`.)
2. **Event-driven drain** — in-process `EventEmitter` (`lib/outboxTrigger.ts`, mirroring existing `listEmitter.ts`); signal after `db.insert(calendarOutbox)` in the three write handlers in `routes/events.ts`; subscribe in `startOutboxWorker()`. The existing `isDraining` guard already covers concurrent invocations. **Redis pub/sub is wrong here** — the drain is single-process by design.
3. **VALARM write path**`buildVeventString` in `broker/vevent.ts` gains a `reminderMinutes?` param using `ICAL.Component('valarm')` + `ICAL.Duration.fromSeconds` (same ical.js surface as RRULE). `eventFieldsSchema` (routes/events.ts) and `outboxPayloadSchema` (outboxWorker.ts) must change in sync (flagged by the IN-03 comment).
@@ -92,10 +96,10 @@ Tracks 7 and 8 have no code dependencies and can run parallel to anything.
## Confidence
| Domain | Confidence | Notes |
|--------|------------|-------|
| Stack | HIGH | v1.0 proven; one dev dep; Gitea syntax compatible (MariaDB 11 healthcheck caveat noted) |
| Features | HIGH | All have prior art; scoped to a tiny household |
| Architecture | HIGH | Grounded in real v1.0 source; component boundaries + build order sound |
| Pitfalls | HIGH | 15 pitfalls from codebase review + RFC 5545 + v1.0 retrospective, each mapped to a phase |
| Unraid CI runner | MEDIUM | Runner Docker-socket/Node/pnpm state unknown until probed |
| Domain | Confidence | Notes |
| ---------------- | ---------- | ---------------------------------------------------------------------------------------- |
| Stack | HIGH | v1.0 proven; one dev dep; Gitea syntax compatible (MariaDB 11 healthcheck caveat noted) |
| Features | HIGH | All have prior art; scoped to a tiny household |
| Architecture | HIGH | Grounded in real v1.0 source; component boundaries + build order sound |
| Pitfalls | HIGH | 15 pitfalls from codebase review + RFC 5545 + v1.0 retrospective, each mapped to a phase |
| Unraid CI runner | MEDIUM | Runner Docker-socket/Node/pnpm state unknown until probed |
+5
View File
@@ -3,6 +3,7 @@
## Open
### RQ-003 — Fastmail calendar API: JMAP vs CalDAV
**Question**: For the custom app to read AND write the shared family calendar on Fastmail, is JMAP
or CalDAV the cleaner integration? Can a scoped Fastmail API token grant calendar read-write without
exposing the full account?
@@ -19,6 +20,7 @@ create/update/delete works via token. Compare to CalDAV (sabre-dav style) effort
## Resolved
### RQ-001 — Vikunja MariaDB compatibility — SUPERSEDED
Vikunja was dropped from the architecture (lists now live in the custom app's own MariaDB). The
MariaDB findings still apply to the custom app itself: use MariaDB 10.6+, set `utf8mb4` collation,
pin versions before upgrades.
@@ -26,6 +28,7 @@ pin versions before upgrades.
---
### RQ-002 — CalDAV server: Radicale vs Baikal — SUPERSEDED
Both dropped. The shared calendar now lives on **Fastmail**, not a self-hosted CalDAV server.
Rationale: the Fastmail Android app cannot display a self-hosted CalDAV calendar, so Baikal gave the
primary (Android/Fastmail) user no native benefit — it only helped Apple members, who are equally
@@ -38,9 +41,11 @@ Original finding (retained for reversibility): if self-hosting the calendar is e
---
### Calendar host decision — RESOLVED
Self-hosting the calendar data was evaluated and rejected. Fastmail (already paid for) hosts the
shared family calendar. Other household members are Apple — they reach it via the PWA (default) or
native Apple Calendar over CalDAV (optional).
### Email scope — RESOLVED
Out of scope. Members keep existing mail clients unchanged.