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

8.9 KiB
Raw Blame History

Phase 11: Per-Event Reminders - Context

Gathered: 2026-06-13 Status: Ready for planning

## 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.

## 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).

<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.tsbuildVeventString (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.tscalendarEvents.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_minutesreminderSchedulerdispatchPush → browser push.

</code_context>

## 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").
## 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.

Phase: 11-per-event-reminders Context gathered: 2026-06-13