27 KiB
Feature Research
Domain: Self-hosted family organization hub — operability & polish milestone (v1.1) Researched: 2026-06-10 Confidence: HIGH (v1.0 is shipped; v1.1 features are well-understood domain problems with clear prior art)
Scope of This Document
This document covers only the six v1.1 features. v1.0 features (calendar, event CRUD, lists, push, OIDC, PWA) are shipped and validated; they are listed as dependencies, not re-researched here.
Feature Landscape
Table Stakes (Users Expect These)
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. |
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). |
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 Dependencies
[Per-event reminder UI]
└──requires──> [Event create/edit form] (v1.0, shipped)
└──requires──> [VALARM parse on CalDAV fetch] (tsdav + ical.js, v1.0 partial)
└──requires──> [Push scheduler with per-event fire time] (outbox/scheduler, v1.0 shipped)
└──enhanced by──> [Faster write-back] (drain triggers scheduler sooner)
[Faster write-back]
└──requires──> [Transactional outbox] (v1.0, shipped)
└──enhanced by──> [Per-event reminder UI] (alarm fire times stored at write time)
[Admin Settings UI]
└──requires──> [OIDC login + role field on users table] (v1.0, shipped — needs admin flag)
└──requires──> [App password encrypted storage] (v1.0, shipped)
└──enhances──> [Setup wizard] (wizard hands off to Admin Settings for post-setup credential rotation)
[Setup wizard]
└──requires──> [DB connection] (before anything else can be validated)
└──requires──> [VAPID key generation utility] (web-push keygen, v1.0 shipped in some form)
└──requires──> [OIDC config storage] (env vars or DB config table)
└──gates──> [All other features] (wizard must complete before app is usable)
└──does NOT require──> [Admin Settings UI] (wizard is first-run only; Admin Settings is post-run)
[Gitea CI]
└──requires──> [Gitea act_runner deployed on Unraid host]
└──requires──> [MariaDB service container support in act_runner]
└──requires──> [Vitest unit tests + API integration tests] (v1.0, partially written)
└──enables──> [Mobile-browser test harness] (harness runs as a CI step)
[Mobile-browser test harness]
└──requires──> [Playwright + mobile device config]
└──requires──> [DEV_AUTH_BYPASS or stored auth session]
└──enhanced by──> [Gitea CI] (harness most valuable when gating PRs automatically)
Dependency Notes
- Faster write-back is a force multiplier. It makes the per-event reminder UX feel correct — a user sets a reminder, saves, and expects the scheduler to know about it now, not in 15 seconds. Build faster write-back before or alongside per-event reminders.
- Admin Settings requires an
is_adminflag. v1.0 shipped no role distinction. The schema migration to addusers.is_admin(default false, first user true) is a prerequisite for the Admin Settings route guard and is trivial. - Setup wizard gates the app. Until the wizard completes, no calendar or list feature should render. The wizard is a deploy-time concern for the operator; it is not a UX concern for the non-technical member (she never sees it — the operator runs it once).
- Gitea CI depends on act_runner. If the self-hosted Gitea does not yet have an act_runner container deployed, CI cannot run. This is an infrastructure prerequisite, not a code concern.
- Mobile harness does not block CI. CI (lint/typecheck/unit/integration) can ship first. The mobile harness is an additive step.
MVP Definition (v1.1)
Must Ship (table stakes for "operability" claim)
- Per-event reminder selector — None / preset offsets — single alarm minimum
- Scheduler uses per-event VALARM trigger offset, fires nothing on None
- Existing VALARMs preserved on edit round-trip
- Admin Settings — rotate/re-enter Fastmail app password per member, mark shared calendar
- Setup wizard — DB, OIDC, VAPID, app password — validated before completion
- Faster write-back — event-driven outbox drain, edits land in ~1s perceived
Should Ship (differentiators, don't let them slip the milestone)
- Gitea CI — lint + typecheck + unit + API integration on PR
- Mobile-browser test harness — iPhone viewport, authenticated, usable by assistant in playwright-cli
Defer (not v1.1 scope)
- Multiple reminders per event (2x VALARM) — v1.1 stretch; defer to v1.2 if risky
- Self-service member onboarding via wizard (backlog 999.5) — separate feature, separate phase
- Docker image auto-publish from CI — nice to have; ship manually until CI is stable
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 |
Priority key:
- P1: Must have for milestone claim
- P2: High value, ship if no risk to P1
- P3: Nice to have, defer
Per-Feature Expected Behavior Reference
1. Per-Event Reminders
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 |
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.
Round-trip on edit: On form open for an existing event, read the first VALARM TRIGGER duration from the parsed ical.js component and select the nearest matching preset. If the existing TRIGGER does not match any preset (e.g., TRIGGER:-PT7M from some other client), display a "Custom (preserve)" option and do not strip it on save unless the user explicitly changes the selector. On save with a preset change, replace the VALARM. On save with "None" selected, remove all VALARMs.
Scheduler contract: Store alarm_fire_at (absolute UTC timestamp) in the events or a separate event_alarms table at write time. The scheduler queries alarm_fire_at <= NOW() AND fired = false — no ical re-parse at fire time. This is the existing outbox/scheduler pattern already in v1.0; extend it.
2. Admin Settings
Scope (exactly this, no more):
-
Per-member Fastmail app password — masked input to re-enter or rotate the encrypted credential stored in the DB. Show member name + "last updated" timestamp. On save, attempt a CalDAV
PROPFINDtest request with the new credential before committing. Surface pass/fail inline. No separate page — a section within Settings. -
Shared calendar designation — list all synced calendars (display name + Fastmail collection URL) with a radio button selecting which one is
is_shared = true. Currently requires a direct DB write (D-16 debt). One click + confirm. Show the current selection highlighted.
Role gate: A single users.is_admin TINYINT(1) DEFAULT 0 flag. The first user created during the setup wizard gets is_admin = 1. The route /api/admin/* and the Admin Settings UI section return 403 for non-admin users. No role management UI — the non-technical member never sees this section.
What Admin Settings is NOT: It is not a full server configuration panel, not a user management screen, not an audit log, not a health dashboard. It is two operational tasks that currently require DB console access.
3. Setup Wizard
When it runs: On first visit to the app when a setup_complete record is absent from the DB (or a SETUP_COMPLETE=false env flag, whichever is simpler). After completion, a setup_complete = true record is written and the wizard never appears again.
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 |
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".
Back navigation: Every step allows going back. Already-validated steps retain their values. Do not re-validate automatically on back; re-validate on "Next".
Failure surface: If the DB step fails (impossible at startup in Docker but possible in dev), show a non-wizard error page with setup instructions — the wizard itself cannot run without a DB.
4. Faster Write-Back
Target perceived latency: < 2 seconds from "Save" click to the event appearing correctly in the calendar view. This matches Google Calendar and Fastmail native behavior.
Current latency: Up to ~15 seconds (outbox poll interval) + 5-minute CalDAV read-back poller.
Approach:
- 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.
- On INSERT to the
outboxtable, 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. - On successful CalDAV PUT, emit an SSE
calendar-updatedevent to connected clients so React Query invalidates the calendar cache and re-fetches. The re-fetch is the "write landed" confirmation. - Reduce the read-back poller interval from 5 min to 30 s as a fallback — not the primary path, just the safety net.
Durability guarantee preserved: The outbox row is not deleted until the CalDAV PUT succeeds. The optimistic-202 pattern is unchanged. The drain is just triggered eagerly instead of lazily.
What "near-immediate" does NOT mean: CalDAV is a synchronous HTTP PUT. If Fastmail is slow (>1s), the write takes >1s. The goal is to eliminate the artificial polling delay, not to change network physics.
5. Gitea CI
Trigger: On pull_request to main.
Steps:
- Checkout
- pnpm install (cached)
- TypeScript typecheck —
pnpm -r tsc --noEmit(bothapps/apiandapps/pwa) - ESLint —
pnpm -r lint - Vitest unit tests —
pnpm -r test:unit - API integration tests — spin up MariaDB service container, run
pnpm --filter api test:integrationwithDB_HOST=127.0.0.1
Service container pattern: Gitea Actions uses the same services: syntax as GitHub Actions. MariaDB mariadb:11 with MYSQL_ROOT_PASSWORD, MYSQL_DATABASE env vars. options: --health-cmd="mariadb-admin ping -h localhost" --health-interval=10s --health-retries=5 to gate the test step on DB readiness.
Docker image publish: Separate workflow, trigger push to main (after PR merge). Builds and pushes to the Gitea Container Registry. Not part of the PR workflow — keeps PR checks fast.
Known limitation: Gitea act_runner in Docker has incomplete service volume support. Do not mount host volumes in the services block. The MariaDB service container using env-based config (no volume) is reliable.
6. Mobile-Browser Test Harness
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)
- List co-edit interactions on mobile
What it does NOT cover: Real iOS Safari, Web Push delivery, standalone-mode OIDC redirect, iOS-specific service worker quirks. Those remain human gates.
Integration with CI: A test:e2e:mobile script in apps/pwa/package.json. Run in CI as an optional step (allowed to fail without blocking merge) until the harness is proven stable, then graduate to blocking.
Auth strategy for CI: DEV_AUTH_BYPASS=true with a known test user id. The harness does not run the full OIDC flow — it injects the bypass session directly. This matches the v1.0 dev-stack bring-up pattern.
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 |
Sources
- Apple Calendar default alert settings (Mac support)
- Google Calendar notifications (Android)
- iCalendar RFC 5545 — VALARM component
- RFC 9074 — VALARM Extensions
- iCalendar TRIGGER property spec
- Nextcloud installation wizard
- Wizard UI patterns — LogRocket UX
- NN/G Wizards: Definition and Design Recommendations
- Outbox Pattern — Conduktor
- Transactional Outbox with Optimistic Sending
- Optimistic UI Patterns — Simon Hearne
- Gitea Actions — Act Runner docs
- Gitea Actions Docker builds
- Playwright PWA mobile testing
- Playwright emulation docs
Feature research for: FamilySync v1.1 — Operability & Polish Researched: 2026-06-10