CR-01 (blocker): custom/other-client VALARMs are now preserved on edit via a surfaced reminderIsCustom signal (CAL-14 / Pitfall 1 — previously silently stripped).
CR-02 (blocker): all-day push body no longer reads "Starts in 0 min".
- CAL-14 preserve: UPDATE with no reminderLeadMinutes preserves VALARM from rawVevent
- CAL-13 timed: CREATE with reminderLeadMinutes=15 emits TRIGGER:-PT15M
- CAL-13 clear: UPDATE with reminderLeadMinutes=null emits no VALARM (passes trivially)
- CAL-13 all-day: CREATE with allDay=true and reminderLeadMinutes=1440 emits VALUE=DATE-TIME
- Replace shared+timed filtering tests with NOTIF-04/05 variable-lead tests
- Add timed-0 guard test (D-06: 0 on timed = None — currently FAILING)
- Add personal-calendar dispatch test (isShared restriction dropped)
- Update SINGLE-FIRE test to assert uid:dtstartMs compound key
- Add RESCHEDULE test: new dtstartMs re-fires even for same uid
- Update MISSED-TICK-RECOVERY to use 60s catch-up window
- Add reminderLeadMinutes field to all makeEventRow() calls
- Import classifyValarms from vevent.ts
- Derive reminderLeadMinutesValue: preset/offlist → leadMinutes; custom/none → null
- Add reminderLeadMinutes to .values() and .onDuplicateKeyUpdate({ set: {} })
- Scheduler now has ground truth for native-client VALARMs (T-11-07 mitigated)
- Replace fixed 16-min window with per-event variable-lead fire-time check
- Two separate DB queries: timed (allDay=false) + all-day (allDay=true)
- Remove eq(calendars.isShared, true) — personal events now dispatch (NOTIF-05)
- Remove eq(calendarEvents.allDay, false) — all-day handled in separate query
- Add reminder_lead_minutes IS NOT NULL WHERE predicate (NOTIF-05)
- Skip timed events with reminderLeadMinutes===0 in JS (D-06: 0 on timed = None)
- Change dedup key from bare uid to uid:dtstartMs compound key (NOTIF-06)
- Update prune loop to use compound key
- Import computeAlertInstantUtc from vevent.js (Plan 11-01, wave 2 dep)
- Add humanizeLeadMinutes export (Task 2 body formatter, used in dispatch)
- Update test helper mockTwoQueries() to handle two-query dispatch pattern
- All 14 tests GREEN; tsc --noEmit clean; setInterval retained, no node-cron
- Add reminderLeadMinutes: number | null to CalendarOccurrence interface (D-06)
- Import classifyValarms in expand.ts; derive series-level value once per VEVENT
- Add reminderLeadMinutes to both non-recurring and recurring occurrence construction
- Add reminderLeadMinutes to GET /api/events select for edit-mode pre-population
- Add all-day 9 AM tests: 0-lead fires at EDT alert UTC, not midnight
- Add 1440-lead (day-before) and 10080-lead (7-day-before) tests
- Add all-day dedup test: same uid:dtstartMs fires once across ticks
- Fix all-day prune bug: store start-of-next-day as pruneMs instead of
UTC midnight (which was always <= now by fire time, causing immediate prune)
- Separate dtstartMs (dedup key component) from pruneMs (map cleanup value)
- 28/28 tests GREEN; full API suite 314/314; tsc --noEmit clean
- CalendarOccurrence: required reminderLeadMinutes: number | null (atomic mirror of expand.ts, Plan 11-03)
- CreateEventPayload: optional reminderLeadMinutes?: number | null with absent/null/0/positive contract (D-08)
- Update CalendarOccurrence fixtures in EventForm.test.tsx + EventDetailPopover.test.tsx to include the new required field (reminderLeadMinutes: null)
- pwa tsc --noEmit exits 0
Captured during Phase 11 UAT: dev-bypass user has no calendar provider, blocking hands-on event/reminder testing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- expand.test.ts: 3 new tests asserting reminderIsCustom:true for
absolute DATE-TIME trigger and multi-VALARM, false for relative preset
- EventForm.test.tsx: 3 new tests asserting __custom__ picker init,
'Custom (kept)' option visibility, and payload omits reminderLeadMinutes
- Fixtures: absolute-alarm.ics (DATE-TIME VALARM), multi-alarm.ics (2 VALARMs)
- All 6 new tests FAIL (RED): reminderIsCustom field not yet on interface
- expand.ts: add reminderIsCustom:boolean to CalendarOccurrence interface;
derived from classifyValarms kind==='custom'; propagated to both
non-recurring and recurring occurrence branches
- client.ts: mirror reminderIsCustom on CalendarOccurrence (atomic mirror)
- EventForm.tsx: extend deriveReminderValue to accept isCustom flag;
returns '__custom__' when true, making the existing D-08 preserve branch
live — editing a custom-alarm event now omits reminderLeadMinutes from
the payload so outboxWorker extractValarms keeps the original VALARM
- Fix existing test fixtures (EventForm.test.tsx, EventDetailPopover.test.tsx)
to include reminderIsCustom:false on all CalendarOccurrence literals
Fixes CAL-14 Pitfall 1: Apple Calendar absolute DATE-TIME / multi-VALARM
alarms no longer silently stripped on any edit round-trip from the PWA.
- humanizeLeadMinutes: add isAllDay=false param; all-day branch returns
"Today" (lead=0), "Tomorrow" (1440), "In 1 week" (10080), "In N days" (other)
- byKey map: store isAllDay flag (false for timed, true for all-day)
- dispatch loop: pass event.isAllDay to humanizeLeadMinutes
All-day same-day reminder push now reads "Today" instead of "Starts in 0 min".
Timed event wording unchanged (isAllDay defaults to false).
4 new tests in classifyValarms suite asserting TRIGGER:+PT15M and TRIGGER:PT30M
(positive/no-sign = fires after event) classify as {kind:'custom'}, not as
preset/offlist. Negative triggers regression guards also present.
2 tests FAIL (RED): Math.abs() discards the sign, misclassifies as preset.
classifyValarms: check sign of dur.toSeconds() before preset lookup.
Positive value = alarm fires after event (RFC 5545 TRIGGER:+PT15M or
TRIGGER;RELATED=END:PTNm) → return {kind:'custom'} for preserve path.
Compute leadMinutes as -seconds/60 (was Math.abs) for negative triggers.
Prevents alarm direction inversion: +PT15M was being stored as 15-min-before
lead and re-fired at dtstartUtc-15min — the opposite of the original intent.
- outboxPayloadSchema: 10081 must hard-fail the row (currently dispatches)
- eventFieldsSchema: POST /create with 10081 must 400 (currently 202)
- boundary 10080 and null pass (already correct, no test fails expected)
- eventFieldsSchema (events.ts): rejects reminderLeadMinutes > 10080 with 400
- outboxPayloadSchema (outboxWorker.ts): hard-fails row when value exceeds 1-week cap
- 10080 = 1 week in minutes; matches UI select maximum
- timed event with reminderLeadMinutes=10080 must show 'Custom reminder kept' helper
- currently suppressed: helper text checks !TIMED && !ALLDAY, but 10080 is in ALLDAY
- fix: gate helper text on active preset set only (allDay ? ALLDAY : TIMED)
- helper text condition now uses (allDay ? ALLDAY_REMINDER_PRESETS : TIMED_REMINDER_PRESETS)
- previously checked !TIMED && !ALLDAY: a timed event with 10080 (in ALLDAY set) was
incorrectly treated as 'in presets' and suppressed the helper text
- synthetic option gating for each allDay/timed branch was already correct
One deferred human check: live Fastmail VALARM round-trip + push (untestable in dev, backlog 999.19).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fast-checks failed on 3 no-unnecessary-type-assertion ESLint errors (reminderIsCustom is now a real CalendarOccurrence field). api failed on 4 all-day 9 AM-local tests that assumed a UTC-4 host; CI runs UTC. Pin process.env.TZ=America/New_York in the all-day describe (production code reads TZ at call time, D-04).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Phase 11: Per-Event Reminders
Implements per-event reminder lead times (CAL-13/CAL-14, NOTIF-04/05/06), promoted from backlog 999.4.
What shipped
vevent.ts):buildTimedValarm,buildAllDayValarm,classifyValarms,extractValarms,computeAlertInstantUtc; DURATION trigger (no VALUE=TEXT).reminderScheduler.ts): per-event lead fromreminder_lead_minutes,uid:dtstartMsdedup, dropped fixed-15-min/shared-only restriction, all-day 9 AM-local fire, humanized push body.reminderLeadMinutesthrough route/outbox schemas, preserve-on-no-change (D-08),buildVeventStringwiring, sync-derives-from-VALARM, surfaced onCalendarOccurrence.EventForm.tsx): allDay-aware preset swap, default None, reset-on-toggle, edit pre-population, Custom-kept preserve.Gap-closure (Plan 11-05, from code review)
reminderIsCustomsignal (CAL-14 / Pitfall 1 — previously silently stripped).Verification
🤖 Generated with Claude Code
Task 1 — buildTimedValarm, buildAllDayValarm, VALARM emission in buildVeventString: - buildTimedValarm(leadMinutes): relative DURATION trigger via resetType('duration') + ICAL.Duration.fromSeconds to prevent VALUE=TEXT (Pitfall 2) - buildAllDayValarm(alertInstantUtc): absolute DATE-TIME trigger via resetType('date-time') + ICAL.Time.fromJSDate(utc, true); ensures VALUE=DATE-TIME, no DURATION - NewEventParams extended with reminderLeadMinutes, valarms, allDayAlertInstantUtc - buildVeventString: preserve path (valarms[] wins) → all-day absolute → timed relative; timed 0 = None per D-06; no emission on null/undefined (CAL-13/D-08) Task 2 — classifyValarms, extractValarms (CAL-14): - AlarmClassification type: none | preset | offlist | custom - PRESET_MINUTES set: 0,5,10,15,30,60,120,1440,2880,10080 - classifyValarms: ICAL.parse try/catch → none/custom/preset/offlist via instanceof ICAL.Time - extractValarms: returns live ICAL.Component[] for re-attachment; safe on parse failure Task 3 — computeAlertInstantUtc DST-correct 9 AM local→UTC (NOTIF-06): - Probes UTC offset at 9 AM (not midnight) so spring-forward/fall-back DST transitions before 9 AM resolve with the post-transition offset - Pure Intl.DateTimeFormat arithmetic, no timezone library; verified at 4 DST boundaries- Import classifyValarms from vevent.ts - Derive reminderLeadMinutesValue: preset/offlist → leadMinutes; custom/none → null - Add reminderLeadMinutes to .values() and .onDuplicateKeyUpdate({ set: {} }) - Scheduler now has ground truth for native-client VALARMs (T-11-07 mitigated)- apps/pwa/src/components/EventForm.tsx (Task 2) - apps/api/src/broker/{expand,reminderScheduler,sync,vevent}.ts (Plans 11-01/11-03) - apps/api/tests/broker/{reminderScheduler,sync}.test.ts (Plans 11-01/11-03)4 new tests in classifyValarms suite asserting TRIGGER:+PT15M and TRIGGER:PT30M (positive/no-sign = fires after event) classify as {kind:'custom'}, not as preset/offlist. Negative triggers regression guards also present. 2 tests FAIL (RED): Math.abs() discards the sign, misclassifies as preset.classifyValarms: check sign of dur.toSeconds() before preset lookup. Positive value = alarm fires after event (RFC 5545 TRIGGER:+PT15M or TRIGGER;RELATED=END:PTNm) → return {kind:'custom'} for preserve path. Compute leadMinutes as -seconds/60 (was Math.abs) for negative triggers. Prevents alarm direction inversion: +PT15M was being stored as 15-min-before lead and re-fired at dtstartUtc-15min — the opposite of the original intent.