Files
familysync/.planning/phases/11-per-event-reminders/11-CONTEXT.md
T

131 lines
8.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 11: Per-Event Reminders - Context
**Gathered:** 2026-06-13
**Status:** Ready for planning
<domain>
## Phase Boundary
A per-event reminder lead picker on the event form, round-tripped to Fastmail as a
VALARM, with a variable-lead push scheduler that honors each event's choice. The
preset list, default-None, VALARM DURATION-trigger format, preserve-on-edit
mechanism, `uid:dtstartMs` dedup, variable scan window, dropping the `isShared`-only
reminder restriction, and the scheduler reading `reminder_lead_minutes` from the DB
as ground truth are all **locked by ROADMAP Phase 11 + research/PITFALLS.md** — this
discussion only resolved the product/UX gray areas on top of that.
</domain>
<decisions>
## Implementation Decisions
### Reminder picker — timed vs all-day presets
- **D-01:** Timed events keep the locked preset list: None / 5m / 10m / 15m / 30m / 1h / 2h / 1d / 2d, default **None**.
- **D-02:** All-day events get a **context-swapped, day-granularity** preset list: **None / Same day / 1 day before / 2 days before / 1 week before**, default **None**. The picker is NOT hidden or disabled for all-day events.
- **D-03:** The picker is shown for both event types — it swaps its option set based on the All-day toggle, rather than disappearing.
### All-day reminder semantics
- **D-04:** All-day reminders fire at **9 AM local** on the computed alert day (retains the roadmap's sensible-morning decision; "Same day" = 9 AM on the event's own date, "1 day before" = 9 AM the prior day, etc.).
- **D-05:** All-day leads are stored in the existing `reminder_lead_minutes` column as minutes: Same day = `0`, 1d = `1440`, 2d = `2880`, 1wk = `10080`. The scheduler applies the 9 AM-local rule whenever the event is all-day.
- **D-06:** **`reminder_lead_minutes` must distinguish "no reminder" from "same-day".** No reminder = `NULL` (no VALARM, no push). Same-day all-day = `0` (fire 9 AM on the event date). For timed events `0`/absent still means None. The scheduler and `outboxPayloadSchema` must treat NULL-vs-0 as semantically distinct.
### Existing / non-preset alarms on edit (preserve — CAL-14)
- **D-07:** **Show exact if single.** When an event already carries a reminder that isn't a preset: if it is a *single simple relative alarm*, render its real value in the picker (e.g. "45 min before") even though it's off-list. For *absolute-time triggers or multiple alarms*, show a read-only **"Custom (kept)"** entry.
- **D-08:** In both off-list cases the original VALARM(s) are **preserved verbatim** on save unless the user explicitly selects a preset or None — never silently rewritten or dropped (mirrors the WR-01 RRULE-preserve pattern; `outboxPayloadSchema` distinguishes "no change" from explicit "no reminder").
### Notification copy
- **D-09:** Reminder push body uses **humanized relative** phrasing, largest sensible unit: "Starts in 2 days" / "Starts in 1 hour" / "Starts in 30 min". Replaces the current hardcoded `Starts in ${minutes} min` which breaks for long leads. Title and deep-link `navigate` behavior are unchanged.
### Recurring events
- **D-10:** **Series-level only.** One VALARM on the master event; every occurrence inherits the same lead, and the scheduler fires per occurrence naturally. No per-occurrence (RECURRENCE-ID) reminder override in this phase — see Deferred.
### Claude's Discretion
- Picker placement within `EventForm.tsx` (reuse the existing labeled `<select>` pattern used for Recurrence).
- Exact humanized-unit thresholds/wording for D-09 (e.g. when to switch min→hour→day).
</decisions>
<roadmap_amendments>
## Roadmap / Requirement Amendments (planner MUST honor)
The all-day decision **reverses a locked roadmap criterion** — surfaced and authorized by the user during discussion:
- **ROADMAP Phase 11 success-criterion 5** and **research/PITFALLS.md Pitfall 3** lock *"the reminder selector is disabled/hidden for all-day events"*. This is **overridden**: the picker is shown for all-day events with the D-02 day-granularity presets. The "9 AM local" fire time (criterion 5 / NOTIF-06) is **retained** and now applies to the chosen day-lead.
- **`buildVeventString`'s `if (!allDay && reminderMinutes > 0)` guard** (Pitfall 3) must change to also emit a VALARM for all-day events with a day-based lead.
- **CAL-13** preset list is extended for the all-day case (day-granularity presets per D-02); timed presets unchanged.
- **NOTIF-06** stays satisfied (9 AM all-day fire) but now governs all-day day-leads, not a hidden/disabled selector.
</roadmap_amendments>
<open_questions>
## Open for Research
- **VALARM trigger encoding for all-day "N days before at 9 AM"** for other-client interop. An all-day VEVENT's DTSTART is a DATE (midnight); a bare relative DURATION trigger fires at midnight, not 9 AM. Research the best-effort trigger representation (relative DURATION offset vs absolute trigger) that other clients (Fastmail/Apple) honor reasonably. The **scheduler remains ground truth** for the actual push fire time (9 AM local via D-04/D-05), so interop fidelity here is best-effort, not exactly-once-critical.
- Confirm `eventFieldsSchema` / `outboxPayloadSchema` can carry the NULL-vs-0 distinction (D-06) end to end.
</open_questions>
<canonical_refs>
## Canonical References
**Downstream agents MUST read these before planning or implementing.**
### Phase scope & requirements
- `.planning/ROADMAP.md` §"Phase 11: Per-Event Reminders" — goal, success criteria, owned pitfalls (note criterion-5 amendment above).
- `.planning/REQUIREMENTS.md` — CAL-13, CAL-14 (lines 1516); NOTIF-04, NOTIF-05, NOTIF-06 (lines 2123).
- `.planning/research/PITFALLS.md` — Pitfalls 1 (preserve-on-edit), 2 (no TRIGGER VALUE=TEXT), 3 (all-day guard — amended), 4 (`uid:dtstartMs` dedup).
### Implementation surface (existing code this phase modifies)
- `apps/api/src/broker/vevent.ts``buildVeventString` (VALARM emission, allDay guard to amend).
- `apps/api/src/broker/write.ts` — write-back path; VALARM extraction/preserve from `rawVevent`.
- `apps/api/src/broker/outboxWorker.ts` + `outboxPayloadSchema` — "no change" vs explicit "no reminder" distinction.
- `apps/api/src/broker/reminderScheduler.ts` — variable per-event window, `uid:dtstartMs` dedup, drop `isShared`-only restriction, read `reminder_lead_minutes`, all-day 9 AM rule, humanized body (D-09).
- `apps/api/src/db/schema.ts``calendarEvents.reminder_lead_minutes` (NULL-vs-0 semantics, D-06).
- `apps/pwa/src/components/EventForm.tsx` — reminder `<select>` (reuse Recurrence-select pattern; allDay-aware preset swap).
</canonical_refs>
<code_context>
## Existing Code Insights
### Reusable Assets
- `EventForm.tsx` already renders a labeled `<select>` for Recurrence (~line 852) — the reminder picker reuses this exact pattern, including the allDay-conditional rendering already present for time fields (`{!allDay && (...)}`).
- `reminderScheduler.ts` already has the dispatch loop, per-event/per-sub error isolation, deep-link `navigate`, and the prune step — Phase 11 changes the *query window, dedup key, lead source, and body text*, not the dispatch plumbing.
- `dispatchPush` / `pushDispatcher.ts` push pipeline reused unchanged.
### Established Patterns
- **WR-01 RRULE-preserve** in the write path is the template for VALARM preserve-on-edit (D-08): extract sub-components from `rawVevent`, re-attach rather than rebuild.
- Phase 10 added `reminder_lead_minutes` as the scheduler's ground truth — the column already exists from the v1.1 migration (use generate+migrate if any column change is needed, **never** `drizzle-kit push`).
- `setInterval`-only scheduling (node-cron silently skips ticks in the long-lived process — do not reintroduce).
### Integration Points
- Event form → `outboxPayloadSchema` → outbox worker → `buildVeventString` → Fastmail PUT.
- DB `reminder_lead_minutes``reminderScheduler``dispatchPush` → browser push.
</code_context>
<specifics>
## Specific Ideas
- The current scheduler body is literally `Starts in ${minutes} min` (reminderScheduler.ts:157) — that is the string being replaced by D-09.
- All-day "Same day" reminder was explicitly requested by the user as a useful heads-up case ("why not").
</specifics>
<deferred>
## Deferred Ideas
- **Per-occurrence reminder override** (RECURRENCE-ID) — flexible but significantly more complex; its own phase if ever wanted (D-10 keeps this phase series-level).
- **Reminder snooze / notification-preferences UI** — already declared out of scope in REQUIREMENTS.md (over-build for a 2-member household).
### Reviewed Todos (not folded)
- `2026-06-13-pwa-phone-bottombar-overlap.md` — BottomTabBar overlaps the New Event FAB/legend. Belongs to **Phase 17 (UI Optimization & Polish)**, not reminders.
- `2026-06-10-gitea-ci-regression-and-docker-publish.md` — CI regression/Docker publish. Tooling/CI scope (Phase 8 / backlog), unrelated to reminders.
</deferred>
---
*Phase: 11-per-event-reminders*
*Context gathered: 2026-06-13*