docs(11): capture phase context for per-event reminders
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
# 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 15–16); NOTIF-04, NOTIF-05, NOTIF-06 (lines 21–23).
|
||||
- `.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*
|
||||
@@ -0,0 +1,72 @@
|
||||
# Phase 11: Per-Event Reminders - Discussion Log
|
||||
|
||||
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
|
||||
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
|
||||
|
||||
**Date:** 2026-06-13
|
||||
**Phase:** 11-per-event-reminders
|
||||
**Areas discussed:** Existing-alarm display, All-day treatment, Notification copy, Recurring events
|
||||
|
||||
---
|
||||
|
||||
## Existing-alarm display (edit / preserve)
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| "Custom (kept)" sentinel | Read-only Custom entry; preserve verbatim unless changed | |
|
||||
| Snap to nearest preset | Show nearest preset; risks silent rewrite | |
|
||||
| Show exact if single | Render real value for single relative alarm; "Custom" for absolute/multiple | ✓ |
|
||||
|
||||
**User's choice:** Show exact if single.
|
||||
**Notes:** Off-list single relative alarm shows its real value ("45 min before"); absolute-time or multiple alarms → read-only "Custom (kept)", preserved verbatim (CAL-14).
|
||||
|
||||
---
|
||||
|
||||
## All-day treatment
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Hidden when all-day | Picker disappears once All-day toggled on | |
|
||||
| Shown but disabled | Picker greyed with a hint | |
|
||||
| (Free text) All-day fires reminders like any event, in days | User override of the locked roadmap criterion | ✓ |
|
||||
|
||||
**User's choice:** All-day events DO get reminders, with day-granularity leads.
|
||||
**Notes:** Reverses ROADMAP criterion 5 / PITFALLS Pitfall 3 (which locked selector hidden/disabled for all-day). Follow-up: presets **None / 1d / 2d / 1wk**, fire at **9 AM local**. Later in discussion the user added **"Same day"** to the all-day presets ("why not"), giving None / Same day / 1d / 2d / 1wk.
|
||||
|
||||
---
|
||||
|
||||
## Notification copy
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Humanized relative | "Starts in 2 days" / "in 1 hour" / "in 30 min" | ✓ |
|
||||
| Absolute local time | "Starts at 3:00 PM" / "Tomorrow 9:00 AM" | |
|
||||
| Keep "Starts in N min" | No change | |
|
||||
|
||||
**User's choice:** Humanized relative.
|
||||
**Notes:** Replaces hardcoded `Starts in ${minutes} min` (reminderScheduler.ts:157), which is absurd for long leads.
|
||||
|
||||
---
|
||||
|
||||
## Recurring events
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Series-level only | One VALARM on master; per-occurrence fire via scheduler | ✓ |
|
||||
| Per-occurrence override | RECURRENCE-ID override per occurrence | |
|
||||
|
||||
**User's choice:** Series-level only.
|
||||
**Notes:** Per-occurrence override deferred to a possible future phase.
|
||||
|
||||
---
|
||||
|
||||
## Claude's Discretion
|
||||
|
||||
- Picker placement within `EventForm.tsx` (reuse Recurrence `<select>` pattern).
|
||||
- Exact humanized-unit thresholds/wording for the notification body.
|
||||
|
||||
## Deferred Ideas
|
||||
|
||||
- Per-occurrence (RECURRENCE-ID) reminder override — its own phase if wanted.
|
||||
- Reminder snooze / notification-preferences UI — already out of scope per REQUIREMENTS.md.
|
||||
- Reviewed-not-folded todos: PWA BottomTabBar overlap (Phase 17), Gitea CI regression/Docker publish (Phase 8 / backlog).
|
||||
Reference in New Issue
Block a user