Commit Graph
100 Commits
Author SHA1 Message Date
Lucas Berger 6cdc1ed22e docs(18): create phase plan 2026-06-14 21:53:38 -04:00
Lucas Berger 68a315e164 docs(18): create phase plan (4 plans, 3 waves) 2026-06-14 21:51:13 -04:00
Lucas Berger efae7159a4 docs(18): add validation strategy 2026-06-14 21:41:06 -04:00
Lucas Berger 12fb5d2adb docs(18): research phase domain 2026-06-14 21:39:56 -04:00
Lucas Berger 79111232b6 docs(state): record phase 18 context session 2026-06-14 21:25:46 -04:00
Lucas Berger 5d961b7c37 docs(18): capture phase context 2026-06-14 21:25:40 -04:00
Lucas BergerandClaude Sonnet 4.6 8be2b533d2 docs(phase-12): UI design contract for Initial Setup Wizard
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 21:16:23 -04:00
Lucas BergerandClaude Opus 4.8 eff9b13c66 fix(11): make CI green — pin TZ in all-day scheduler tests, drop redundant casts
CI / changes (pull_request) Successful in 2s
CI / fast-checks (pull_request) Successful in 1m25s
CI / api (pull_request) Successful in 1m3s
CI / harness (pull_request) Successful in 4m14s
CI / security (pull_request) Successful in 41s
CI / gate (pull_request) Successful in 1s
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>
2026-06-14 11:22:04 -04:00
Lucas BergerandClaude Opus 4.8 156bdf057b docs(phase-11): evolve PROJECT.md — per-event reminders validated
CI / changes (pull_request) Successful in 3s
CI / fast-checks (pull_request) Failing after 50s
CI / api (pull_request) Failing after 1m3s
CI / harness (pull_request) Successful in 4m12s
CI / security (pull_request) Successful in 40s
CI / gate (pull_request) Failing after 1s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 08:32:23 -04:00
Lucas BergerandClaude Opus 4.8 ff06a8479b docs(phase-11): complete phase execution (5/5 plans, CAL-13/14 + NOTIF-04/05/06)
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>
2026-06-14 08:31:31 -04:00
Lucas BergerandClaude Opus 4.8 c86020ac21 docs(phase-11): mark gap-closure plan 11-05 complete; post-merge gate green (typecheck, API 347, PWA 206)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 08:30:51 -04:00
Lucas Berger 9bc6c7274c docs(11-05): complete gap-closure plan — SUMMARY 2026-06-14 08:28:24 -04:00
Lucas Berger a3aec2d21d style(11-05): prettier format EventForm.test.tsx WR-03 additions 2026-06-14 08:26:00 -04:00
Lucas Berger a04c76b823 fix(11-05): WR-03 gate helper text on active preset set only
- 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
2026-06-14 08:24:24 -04:00
Lucas Berger 401591374a test(11-05): RED — WR-03 helper text suppressed for timed off-list 10080
- 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)
2026-06-14 08:24:01 -04:00
Lucas Berger 7d94afb2d8 fix(11-05): WR-02 add .max(10080) to reminderLeadMinutes in both Zod schemas
- 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
2026-06-14 08:22:13 -04:00
Lucas Berger 30b8c9643a test(11-05): RED — WR-02 reminderLeadMinutes max(10080) in both Zod schemas
- 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)
2026-06-14 08:21:44 -04:00
Lucas Berger bc605e6a42 fix(11-05): WR-01 positive-duration TRIGGER classifies as custom (no Math.abs)
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.
2026-06-14 08:15:31 -04:00
Lucas Berger d18aba7816 test(11-05): RED — WR-01 positive-duration TRIGGER classifies as custom
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.
2026-06-14 08:15:06 -04:00
Lucas Berger 16ac235476 fix(11-05): CR-02 all-day-aware push body (no "Starts in 0 min")
- 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).
2026-06-14 08:14:08 -04:00
Lucas Berger 1caa2e36d2 test(11-05): RED — CR-02 all-day-aware humanizeLeadMinutes
5 new tests asserting isAllDay=true branch: lead=0→"Today", 1440→"Tomorrow",
2880→"In 2 days", 10080→"In 1 week"; timed (isAllDay=false) behavior unchanged.
All 5 FAIL (RED): humanizeLeadMinutes only accepts one argument.
2026-06-14 08:12:56 -04:00
Lucas Berger f6b47ebf1e fix(11-05): CR-01 surface reminderIsCustom to preserve custom VALARMs on edit
- 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.
2026-06-14 08:11:46 -04:00
Lucas Berger 5d6cb47191 test(11-05): RED — CR-01 custom alarm round-trip
- 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
2026-06-14 08:08:24 -04:00
Lucas BergerandClaude Opus 4.8 6cdf9d22f9 docs(11): gap-closure plan 11-05 — fix CR-01/CR-02 blockers + WR-01/02/03 from code review
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 08:03:44 -04:00
Lucas BergerandClaude Opus 4.8 89132be3be docs(11): code review (2 blockers, 3 warnings) + goal verification (5/5, 1 live-check deferred)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 08:02:08 -04:00
Lucas BergerandClaude Opus 4.8 213f2547cf docs: backlog 999.19 — dev user full app functionality without real-calendar sync
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>
2026-06-14 06:59:35 -04:00
Lucas Berger 8cd4c0e2c2 docs(11-04): complete reminder picker plan — SUMMARY, STATE, ROADMAP, REQUIREMENTS
- 11-04-SUMMARY.md: allDay-aware select, edit pre-population, Custom-kept limitation, playwright smoke results
- STATE.md: advance plan counter, add key decisions, record session
- ROADMAP.md: phase 11 now Complete (4/4 summaries)
- REQUIREMENTS.md: CAL-13 + CAL-14 marked complete
2026-06-14 06:58:18 -04:00
Lucas Berger b9b3191b5b style(11-04): apply prettier to phase-11 modified files
- 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)
2026-06-14 06:54:11 -04:00
Lucas Berger fe549ef2b0 feat(11-04): add reminder picker to EventForm (allDay swap, edit pre-population, payload mapping)
- TIMED_REMINDER_PRESETS + ALLDAY_REMINDER_PRESETS sets for preset classification
- humanizeReminderLead() + deriveReminderValue() helpers for off-list synthetic options
- reminderValue state (__none__ / numeric string / __custom__ sentinels)
- allDay toggle resets reminderValue to __none__ (D-03 — no carry-over)
- Reset effect derives reminderValue from occurrence.reminderLeadMinutes on mount/cache update
- Reminder <select id=event-reminder> after Recurrence picker: allDay-conditional option swap (D-02), timed presets when !allDay, day-granularity presets when allDay; synthetic off-list option; Custom (kept) read-only disabled option
- Helper text under select in edit mode for off-list/custom states (D-07)
- executeSubmit payload: __none__ → null, numeric → integer, __custom__ → field omitted (D-08)
- 10 new Phase 11 reminder picker tests (create/allDay-swap/edit-pre-pop/payload-mapping); fix pre-existing None-ambiguity test
- pwa vitest 201/201 pass; tsc --noEmit clean
2026-06-13 22:36:31 -04:00
Lucas Berger 2c30afe8ff feat(11-04): add reminderLeadMinutes to CreateEventPayload + CalendarOccurrence
- 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
2026-06-13 22:33:06 -04:00
Lucas BergerandClaude Opus 4.8 ca76b6118e docs(phase-11): update tracking after wave 2
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 22:29:09 -04:00
Lucas Berger adf5d13e28 merge(phase-11): wave 2 plan 11-03 backend plumbing 2026-06-13 22:27:54 -04:00
Lucas Berger 23a773edc3 docs(11-02): complete variable-lead scheduler plan — 28/28 tests, 314/314 suite
SUMMARY.md covers: uid:dtstartMs dedup, dropped isShared restriction,
all-day 9 AM branch, humanizeLeadMinutes buckets, pruneMs split fix.
Requirements NOTIF-04/05/06 claimed by automated tests.
2026-06-13 22:27:12 -04:00
Lucas Berger 0dc227a863 feat(11-02): Task 3 — all-day 9 AM-local fire branch + dedup prune fix (NOTIF-06)
- 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
2026-06-13 22:25:54 -04:00
Lucas Berger 72773a35eb docs(11-03): complete Plan 03 — reminderLeadMinutes end-to-end plumbing
- Schema field + VALARM wiring in outbox worker (CAL-13/CAL-14)
- sync.ts VALARM classification → reminderLeadMinutes upsert (D-07/NOTIF-05)
- CalendarOccurrence.reminderLeadMinutes + GET select (D-10)
- 13 new TDD tests; 132/132 broker tests pass; tsc clean
2026-06-13 22:24:01 -04:00
Lucas Berger 57f9d67685 feat(11-02): Task 2 — humanizeLeadMinutes tests + body dispatch assertion (D-09)
- Add 8 bucket tests: 30→'30 min', 59→'59 min', 60→'1 hr', 90→'1 hr',
  120→'2 hrs', 1440→'1 day', 2880→'2 days', 10080→'7 days'
- Add body-in-dispatch test: 1440-min lead → body='Starts in 1 day'
  (driven by configured lead, not live minutes-to-start delta)
- humanizeLeadMinutes implementation already committed in Task 1 GREEN
- All 23 tests GREEN
2026-06-13 22:22:03 -04:00
Lucas Berger e1714316be feat(11-03): surface reminderLeadMinutes on CalendarOccurrence + GET select (D-10)
- 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
2026-06-13 22:21:42 -04:00
Lucas Berger 62d3f58684 feat(11-02): GREEN Task 1 — variable-lead window, uid:dtstartMs dedup, drop isShared restriction
- 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
2026-06-13 22:20:51 -04:00
Lucas Berger 7df11d2780 test(11-03): add failing tests for reminderLeadMinutes on CalendarOccurrence (D-06/D-10)
- non-recurring event: occurrence carries reminderLeadMinutes=30 from master
- all-day event with 0-minute trigger: occurrence carries 0 (NULL-vs-0, D-06)
- no VALARM: occurrence carries reminderLeadMinutes=null
- D-10 series-level: all recurring occurrences inherit master's reminderLeadMinutes=60
2026-06-13 22:19:33 -04:00
Lucas Berger 1cc0d7278a feat(11-03): sync.ts derives reminderLeadMinutes from VALARM classification (D-07/NOTIF-05)
- 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)
2026-06-13 22:18:46 -04:00
Lucas Berger cdca93094a test(11-03): add failing tests for sync.ts reminderLeadMinutes upsert from VALARM
- preset TRIGGER:-PT30M → reminderLeadMinutes=30
- no VALARM → reminderLeadMinutes=null
- absolute DATE-TIME trigger → null (custom kind, D-07/NOTIF-05)
- two VALARMs → null (multiple alarms not resolvable to single lead)
- onDuplicateKeyUpdate set also carries reminderLeadMinutes (upsert keeps column current)
2026-06-13 22:17:51 -04:00
Lucas Berger 4f42b7535b feat(11-03): reminderLeadMinutes schema + VALARM wiring in outbox worker (CAL-13/CAL-14)
- Add reminderLeadMinutes to eventFieldsSchema (ingress validation, T-11-06)
- Add reminderLeadMinutes to outboxPayloadSchema (drain re-validation, IN-03 defense-in-depth)
- Import extractValarms + computeAlertInstantUtc from vevent.ts
- UPDATE branch: hasExplicitReminder gate mirrors hasExplicitRecurrence (WR-01 pattern)
  - absent field → extractValarms(rawVevent) preserved verbatim (CAL-14, D-08)
  - explicit null → clear VALARM (no valarmsToPreserve, null passed to buildVeventString)
  - explicit value + allDay → computeAlertInstantUtc at 9 AM local (D-04)
  - explicit value + timed → passed through to buildTimedValarm via buildVeventString
- CREATE branch: always explicit picker value; compute allDayAlertInstantUtc when allDay
2026-06-13 22:16:42 -04:00
Lucas Berger 9635aa9e8e test(11-02): RED — variable-lead, uid:dtstartMs dedup, NULL-vs-0, personal calendar tests
- 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
2026-06-13 22:15:55 -04:00
Lucas Berger 79f6871167 test(11-03): add failing tests for reminderLeadMinutes VALARM wiring (CAL-13/CAL-14)
- 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
2026-06-13 22:14:42 -04:00
Lucas BergerandClaude Opus 4.8 f3b74cc87e docs(phase-11): update tracking after wave 1
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 22:10:22 -04:00
Lucas Berger d80a9589ac docs(11-01): complete VALARM serialization + classification plan summary
- 37/37 vevent.test.ts pass (RED→GREEN TDD gate complete)
- 296/296 full API suite pass
- tsc --noEmit clean
- 7 new exported symbols; DST probe-at-9AM deviation documented
2026-06-13 22:06:11 -04:00
Lucas Berger d9eb5c1875 feat(11-01): implement VALARM builders, classifier, extractor, computeAlertInstantUtc
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
2026-06-13 22:04:18 -04:00
Lucas Berger 860c7419ac test(11-01): RED — VALARM builders, classifier, extractor, computeAlertInstantUtc
- Add failing tests for buildTimedValarm, buildAllDayValarm (no VALUE=TEXT)
- Add failing tests for buildVeventString VALARM emission (timed/all-day/null/preserve)
- Add failing tests for classifyValarms (none/preset/offlist/custom)
- Add failing tests for extractValarms (round-trip, empty, garbage)
- Add failing tests for computeAlertInstantUtc DST boundaries (spring/fall/summer/winter)
- Import ICAL from ical.js in test file for Component instanceof checks
2026-06-13 21:59:45 -04:00
Lucas BergerandClaude Opus 4.8 7d070a6b69 docs: add Phase 18 (timezone) to roadmap; refresh knowledge graph
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 21:54:54 -04:00
Lucas Berger e169b138f9 docs(11): sequence plan 11-02 behind 11-01, cite D-09; record planning completion 2026-06-13 21:26:16 -04:00
Lucas Berger 6109d4ca49 docs(11): add pattern map for per-event reminders 2026-06-13 21:20:55 -04:00
Lucas Berger cfeb8d8660 docs(11): create per-event-reminders phase plan (4 plans, 3 waves) 2026-06-13 21:20:09 -04:00
Lucas Berger a3c4aea9b1 docs(11): add Nyquist validation strategy for per-event reminders 2026-06-13 21:07:14 -04:00
Lucas Berger feec030509 docs(11): research phase — VALARM, NULL-vs-0 schema, off-list classifier, variable-lead scheduler 2026-06-13 21:04:32 -04:00
Lucas Berger 4e09ebdb75 docs(11): record UI-SPEC approval in state 2026-06-13 20:53:56 -04:00
Lucas Berger ecdd94a907 docs(11): UI design contract for per-event reminders 2026-06-13 20:52:33 -04:00
Lucas Berger 771608d3d4 docs(11): capture phase context for per-event reminders 2026-06-13 20:49:25 -04:00
Lucas Berger 4517432dca style(quick-260613-ndv): prettier-format global-setup.ts
CI / changes (pull_request) Successful in 2s
CI / fast-checks (pull_request) Successful in 1m24s
CI / api (pull_request) Successful in 1m3s
CI / harness (pull_request) Successful in 4m9s
CI / security (pull_request) Successful in 39s
CI / gate (pull_request) Successful in 1s
2026-06-13 17:15:45 -04:00
Lucas Berger e687cb96e7 docs(quick-260613-ndv): plan + summary + state for test-DB isolation 2026-06-13 17:15:45 -04:00
Lucas Berger 24cb7569bf fix(quick-260613-ndv): reset familysync_test each run (truncate-all)
CREATE DATABASE IF NOT EXISTS reuses the prior run's data, so the test DB
would itself accumulate users run-over-run (the same flaky list_shares
fan-out the dev DB suffered). Truncate every table (except the drizzle
migration ledger) at globalSetup start, FK-safe, so each run is a clean
deterministic slate. Verified: familysync_test users 186 -> 93 across a run
(reset, not doubled); dev familysync untouched at 3.
2026-06-13 17:14:51 -04:00
Lucas Berger f39bd308b2 chore(quick-260613-ndv-02): clean-slate comment in setup.ts + README local-test docs
- Update apps/api/test/setup.ts header: clarify tests run against familysync_test
  (provisioned by global-setup.ts), document users-cleanup decision (intact across
  tests), and note CI-vs-local env difference
- Add apps/api/README.md "Running API tests locally" section: documents the test
  DB isolation, run command, DB_ROOT_PASSWORD requirement, and CI no-op behaviour
- Fix apps/api/test/global-setup.ts: switch from drizzle({ client, mode }) to
  drizzle(pool, { mode }) — drizzle-orm@0.45.2 isConfig() has a tautological OR
  in the `mode` branch that always returns false, causing the combined-config form
  to pass the config object as the client (client.query is not a function); two-arg
  form routes correctly; 244/244 tests pass against familysync_test
2026-06-13 17:14:51 -04:00
Lucas Berger 1eda6678bc chore(quick-260613-ndv-01): add globalSetup for familysync_test isolation
- Create apps/api/test/global-setup.ts: root-provisions + grants + migrates
  familysync_test (local only); no-op when process.env.CI is truthy (T-ndv-04)
- Update apps/api/vitest.config.ts: wire globalSetup; add CI-gated test.env
  override (DB_NAME=familysync_test, DB_HOST) so workers never touch dev DB
2026-06-13 17:14:51 -04:00
Lucas Berger 6dff7aa012 docs(roadmap): add Phase 17 (UI Optimization & Polish) + document phone bottom-bar overlap
Long-standing PWA phone-layout defect (BottomTabBar from Phase 04, not a
Phase 10 regression): the fixed bottom bar overlaps the New Event FAB and
the calendar colour legend at <=767px. Added Phase 17 to the v1.1 roadmap
with the defect as the seed item + fix sketch, and a pending todo
(resolves_phase: 17) with full repro/file detail.
2026-06-13 16:43:00 -04:00
Lucas Berger 2067b8c91c docs(phase-10): evolve PROJECT.md after phase completion
CI / changes (pull_request) Successful in 2s
CI / fast-checks (pull_request) Successful in 1m25s
CI / api (pull_request) Successful in 1m3s
CI / harness (pull_request) Successful in 4m17s
CI / security (pull_request) Successful in 39s
CI / gate (pull_request) Successful in 1s
2026-06-13 15:47:15 -04:00
Lucas Berger 94c62e213e docs(phase-10): complete phase execution 2026-06-13 15:46:09 -04:00
Lucas Berger 2f347cbd98 fix(10): guard shared-calendar designation against non-existent target (CR-01)
PUT /api/admin/calendars/:id/shared cleared the current shared calendar then
set the target in two non-transactional UPDATEs without checking the target
exists — a bad/stale id wiped the family shared lane and still returned ok.
Verify the target inside a transaction; return 404 when absent. Adds a
regression test (RED→GREEN).
2026-06-13 15:38:35 -04:00
Lucas Berger ebde3e1d08 docs(10): add code review report 2026-06-13 15:35:03 -04:00
Lucas Berger 3693da6d0b docs(10-04): complete PWA admin surfaces plan
- SUMMARY.md for Plan 04 (AdminPage, CredentialSheet, SetupBanner, nav gating)
- STATE.md: plan advanced to last, progress 100%, decisions recorded
- ROADMAP.md: Phase 10 marked Complete (4/4 summaries)
2026-06-13 15:28:03 -04:00
Lucas Berger 79fe3e0e04 fix(10-04): prettier format + remove unnecessary type assertions
- Run prettier on all new/modified PWA files (CredentialSheet, SetupBanner, AdminPage, admin.spec.ts)
- Remove unnecessary 'as React.RefObject<HTMLElement | null>' casts flagged by @typescript-eslint/no-unnecessary-type-assertion
- Format pre-existing API files from Plans 02/03 (me.ts, user.test.ts, requireAdmin.test.ts, me.test.ts)
- All 270 API tests + 191 PWA vitest tests pass; lint/typecheck/build clean
2026-06-13 15:22:50 -04:00
Lucas Berger 7808426a2f feat(10-04): AdminPage + /admin route + conditional nav entries + e2e spec
- AdminPage: Admin Settings heading, MEMBERS section (avatar+status+action), SHARED CALENDAR radio group + two-tap Save + empty state
- App.tsx: /admin route gated by meQuery.data.user.isAdmin (loading gate prevents flash), SetupBanner mounted above content, BottomTabBar + AppNav receive isAdmin
- AppNav.tsx: ShieldCheck Admin nav entry rendered only when isAdmin=true (D-03 UX gating)
- BottomTabBar.tsx: ShieldCheck Admin tab rendered only when isAdmin=true (D-03 UX gating)
- e2e/admin.spec.ts: 5 assertions across 3 profiles (15 total tests) — admin sees nav+page+members, non-admin: no nav entry + /admin redirects to /calendar
- All 15 e2e tests pass (iphone/pixel/desktop); production build clean
2026-06-13 15:16:11 -04:00
Lucas Berger 2c2c71e7cc feat(10-04): add CredentialSheet and SetupBanner components
- CredentialSheet: admin-rotate/admin-add/self-service modes, role=dialog, aria-modal
- Password field type=password autoComplete=new-password, never pre-filled (T-10-16)
- Fastmail link target=_blank rel=noopener noreferrer (UI-SPEC Surface 3)
- Loader2 spinner + CalDAV failure copy on mutation error
- Success invalidates ['admin','members'] + ['me'] → SetupBanner unmounts
- Escape closes, focus returns to trigger (a11y)
- SetupBanner: renders on needsProviderSetup=true only, role=status aria-live=polite
- KeyRound icon, 'Set up your calendar' heading, 'Set up now' CTA (no X/dismiss)
- Success-only dismissal: ['me'] invalidation is the ONLY code path to hide the banner
- All styling via var(--token); 44px touch targets throughout
2026-06-13 15:03:57 -04:00
Lucas Berger bfe1eff5a3 feat(10-04): extend client.ts — MeUser.isAdmin+needsProviderSetup + admin/self-service fetchers
- Add isAdmin and needsProviderSetup to MeUser interface (D-03 UX gating)
- Add AdminMember, SaveCredentialPayload, AdminCalendar, SaveMyCredentialPayload types
- Add fetchAdminMembers, saveCredential, fetchAdminCalendars, setSharedCalendar fetchers
- Add saveMyCredential (self-service, no userId field — T-10-12/Pitfall 6)
- All fetchers use credentials:'include', redirect:'manual', handleAuthResponse
- Password never logged or stored beyond in-flight request body (T-10-15)
2026-06-13 15:02:06 -04:00
Lucas Berger 0f41993a95 docs(10-03): complete admin-api-surface plan summary and state update 2026-06-13 14:57:30 -04:00
Lucas Berger d2f6d5d77b feat(10-03): implement credentialSync helper, adminRouter, and self-service /api/me/credential
credentialSync.ts:
- validateEncryptAndStoreCredential(userId, email, appPassword, providerType) — single
  shared validate→encrypt→store→initial-sync path used by BOTH admin and self-service
- createFastmailClient + fetchCalendars wrapped in ONE try/catch: any failure throws
  CredentialValidationError (routes map to { error: 'Invalid request' } 400)
- appPassword never logged or echoed (T-10-10)
- encryptPassword (AES-256-GCM) applied before DB write (T-10-11)
- fire-and-forget initial sync via loadClientForUser + syncCalendar (Pitfall 5)

admin.ts:
- adminRouter.use('*', requireAdmin) FIRST (Pitfall 9 / T-10-08)
- GET /members: users LEFT JOIN member_credentials → hasCredential boolean
- POST /credentials: noEchoHook + validateEncryptAndStoreCredential (T-10-09)
- GET /calendars: calendar list (UI-SPEC Surface 5)
- PUT /calendars/:id/shared: exclusive is_shared update (ADMIN-02, D-06)

index.ts:
- app.route('/api/admin', adminRouter) mounted in route block

me.ts:
- POST /credential: member self-service, always currentUserId (Pitfall 6 / T-10-12)
- meCredentialSchema (no userId field), meNoEchoHook, calls shared helper
- All 17 new admin tests pass; 270 total pass; tsc --noEmit clean
2026-06-13 14:54:23 -04:00
Lucas Berger 037a7ed4c1 test(10-03): add RED tests for adminRouter guard, credential no-echo, shared-calendar, self-service
RED phase: all admin.test.ts tests fail (404 — routes/mounts not yet created).
Tests cover:
- T-10-08 Pitfall 9: 403 for non-admin on every /api/admin/* route
- T-10-09 Pitfall 7: 400 with no echoed password for all credential failure modes
  (PROPFIND/auth failure, createFastmailClient throw, network error, schema mismatch)
- T-10-11: valid credential stores encrypted (AES-256-GCM), not plaintext
- ADMIN-02: PUT /api/admin/calendars/:id/shared — exclusive is_shared=1
- T-10-12 Pitfall 6: POST /api/me/credential ignores body userId, writes to session user
- D-07: non-admin member can POST /api/me/credential (no requireAdmin on self-service)
2026-06-13 14:49:35 -04:00
Lucas Berger ac36e106ef feat(10-03): export loadClientForUser and triggerTargetedResync from outboxWorker
- Add export keyword to loadClientForUser (line 271)
- Add export keyword to triggerTargetedResync (line 302)
- Function bodies unchanged (no behavior change, no node-cron reintroduced)
- Enables credentialSync.ts shared helper to call post-credential full-member sync
2026-06-13 14:46:54 -04:00
Lucas Berger a5d88f75aa docs(10-02): complete admin-role-primitives plan summary and state update 2026-06-13 14:39:49 -04:00
Lucas Berger 1adff61cec feat(10-02): extend /api/me with isAdmin + needsProviderSetup (D-03)
- dev-bypass path: DB lookup for users.isAdmin (T-10-05 bypass skips OIDC not DB)
- OIDC path: same resolveAdminAndSetupStatus helper after upsertUser
- needsProviderSetup: true when no member_credentials row, false when one exists
- no /api/me/credential POST added here (Plan 03)
2026-06-13 14:37:38 -04:00
Lucas Berger e5889df03e test(10-02): add failing /api/me isAdmin+needsProviderSetup tests (RED)
- dev-bypass path: isAdmin from DB (not hardcoded), needsProviderSetup from member_credentials
- needsProviderSetup=true when no member_credentials row exists
- needsProviderSetup=false when member_credentials row exists
2026-06-13 14:36:45 -04:00
Lucas Berger 72e0140f01 feat(10-02): add first-login-wins is_admin bootstrap in upsertUser (D-01)
- zero-admin COUNT check before INSERT: first user gets is_admin=true
- subsequent users (admin already exists) get is_admin=false
- existing-user early-return path unchanged (is_admin not modified)
- Phase-12 hook comment: tighten to first login after app_config.setup_complete
- adds 'import { sql }' from drizzle-orm
2026-06-13 14:35:11 -04:00
Lucas Berger 9e1507f7a8 test(10-02): add failing upsertUser is_admin bootstrap tests (RED)
- first user with zero admins → is_admin=true in INSERT values
- subsequent user with admin present → is_admin=false in INSERT values
- existing user re-upsert → is_admin unchanged (early-return path, no insert)
- update existing color tests to accommodate new 4-select flow order
2026-06-13 14:33:53 -04:00
Lucas Berger f9c70ab6a8 feat(10-02): implement requireAdmin DB-backed MiddlewareHandler
- reads users.isAdmin from DB (never trusts context user's isAdmin claim)
- 403 with { error: 'Forbidden' } for non-admins and missing user
- side-effect import of devBypass.js for ContextVariableMap augmentation
- bypass path skips OIDC only, not the DB check (T-10-04/T-10-05)
2026-06-13 14:32:04 -04:00
Lucas Berger 92179302a2 test(10-02): add failing requireAdmin middleware tests (RED)
- 403 for non-admin user (is_admin=false in DB)
- next() called for admin user (is_admin=true in DB)
- 403 when no user on context (no DB query)
- 403 when context user spoofs isAdmin=true but DB has is_admin=false (T-10-04)
2026-06-13 14:30:28 -04:00
Lucas Berger 6405a93742 docs(10-01): complete v1.1 DB foundation plan summary and state update 2026-06-13 14:25:39 -04:00
Lucas Berger bb00c71730 feat(10-01): seed dev-bypass user id=1 as is_admin=true in e2e global-setup
- INSERT INTO users (id=1, is_admin=true) ON DUPLICATE KEY UPDATE is_admin=true (idempotent)
- Supplies placeholder non-null oidc_iss='dev-bypass', oidc_sub='dev-user-1', color='#4A90D9'
- requireAdmin (Plan 02) does a DB lookup for the bypass user; without this seed it would 403
- Existing calendar/event/list seeds unchanged (INSERT IGNORE INTO calendars, Seeded Test Event)
2026-06-13 14:23:09 -04:00
Lucas Berger ad7ba3ae4e chore(10-01): generate + apply v1.1 DB migration (0001_famous_mad_thinker)
- Generated via drizzle-kit generate from updated schema.ts
- SQL is additive-only: CREATE TABLE app_config + ALTER TABLE ADD COLUMN (3x) + ADD CONSTRAINT UNIQUE
- Applied to live dev MariaDB via direct SQL execution (drizzle-kit migrate journal hash mismatch
  with legacy migration tracking; DDL applied + hash recorded in __drizzle_migrations manually)
- Verified: MIGRATION OK via live DB SHOW COLUMNS / SHOW TABLES query
- No DROP/TRUNCATE statements in generated SQL (grep returns 0)
2026-06-13 14:22:45 -04:00
Lucas Berger d0a4cb4e35 feat(10-01): add v1.1 schema bundle (is_admin, provider_type, reminder_lead_minutes, app_config)
- users.isAdmin: boolean NOT NULL DEFAULT false (first-login-wins admin flag, D-01)
- memberCredentials.providerType: varchar(64) NOT NULL DEFAULT 'caldav' (generic provider discriminator, D-04)
- memberCredentials: UNIQUE(user_id) constraint for one-credential-per-member + upsert support (D-05)
- calendarEvents.reminderLeadMinutes: int nullable (created now, consumed by Phase 11)
- appConfig table: key VARCHAR PK, value TEXT, updated_at (setup_complete consumed by Phase 12)
2026-06-13 14:20:10 -04:00
Lucas Berger df92bd89e8 docs(10): point STATE current-phase at Phase 10 2026-06-13 14:13:04 -04:00
Lucas Berger d27b13646c chore(graphs): regenerate knowledge graph 2026-06-13 14:11:39 -04:00
Lucas Berger 159f37fe6a docs(10): record planning completion, annotate roadmap waves, add pattern map 2026-06-13 14:11:39 -04:00
Lucas Berger a944dcd881 docs(10): revise phase plan per plan-checker feedback (3 blockers, 2 warnings) 2026-06-13 14:07:22 -04:00
Lucas Berger b24fbbfde7 docs(10): create phase plan (4 plans, 4 waves) for admin-role-settings
- 10-01 v1.1 DB foundation migration + dev-bypass admin seed
- 10-02 requireAdmin guard + first-login-wins + /api/me extension (TDD)
- 10-03 adminRouter credentials/shared-calendar + member self-service (TDD)
- 10-04 PWA /admin route + nav gating + CredentialSheet + SetupBanner
- filled 10-VALIDATION Per-Task Verification Map (Nyquist compliant)
- finalized ROADMAP Phase 10 plan list
2026-06-13 13:57:51 -04:00
Lucas Berger 7af08c97c5 docs(10): add validation strategy 2026-06-13 13:40:41 -04:00
Lucas Berger 382b346b16 docs(10): research phase — admin role & settings 2026-06-13 13:38:51 -04:00
Lucas Berger ca6947cfbf docs: add backlog item 999.18 — update dependencies as found during ci 2026-06-13 13:23:32 -04:00
Lucas BergerandClaude Sonnet 4.6 de092e84c0 docs(10): UI design contract for admin-role-settings phase
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 13:20:40 -04:00
Lucas Berger cb3ae9081f docs(10): fold backlog 999.5 self-service onboarding into CONTEXT (D-07), reconcile with ROADMAP 2026-06-13 13:17:22 -04:00
Lucas Berger 15fa89d483 docs(quick-260613-fp9): document ci.yml heavy-job paths-filter fix
CI / changes (pull_request) Successful in 3s
CI / fast-checks (pull_request) Successful in 1m36s
CI / api (pull_request) Has been skipped
CI / harness (pull_request) Has been skipped
CI / security (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 2s
2026-06-13 12:13:58 -04:00
Lucas Berger ec85fe026a ci(260613-fp9): fix changes paths-filter so doc/.gitea/.planning PRs skip heavy jobs
dorny/paths-filter combines patterns with Array.some, and picomatch compiles
'!.gitea/**' as 'any path not under .gitea'. The '- !.gitea/**' line (from
quick task 260613-dmw) therefore matched every non-.gitea file — including
.planning/** and *.md — so code=true for doc-only PRs and the heavy api/harness
jobs ran anyway. It also never excluded .gitea (the **/*.yml glob already
matched workflow files), regressing the Phase 15 doc-only skip.

Switch to predicate-quantifier 'every' with negation-only globs: a file counts
as code only if it is outside .gitea/, outside .planning/, and not Markdown.
Verified with picomatch against representative file sets.
2026-06-13 12:13:58 -04:00
Lucas Berger 4d4ffad059 docs(quick-260613-fp9): .gitea/.planning pushes should not trigger a docker image publish
CI / changes (pull_request) Successful in 2s
CI / fast-checks (pull_request) Successful in 1m24s
CI / api (pull_request) Successful in 1m0s
CI / harness (pull_request) Successful in 3m53s
CI / security (pull_request) Successful in 39s
CI / gate (pull_request) Successful in 1s
2026-06-13 11:22:16 -04:00