Commit Graph
52 Commits
Author SHA1 Message Date
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 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 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 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 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 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 adf5d13e28 merge(phase-11): wave 2 plan 11-03 backend plumbing 2026-06-13 22:27:54 -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 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 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 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 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 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 Berger 59e49ec3da chore(16-03): triage eslint-plugin-security findings to green
- Disable detect-object-injection globally in eslint.config.js: all hits were
  numeric loop array indices (ranks[i]) — not user-controlled keys; zod guards
  real API input boundaries; justification comment added (T-16-09)
- Add inline eslint-disable for detect-non-literal-fs-filename at 2 sites:
  - apps/api/src/index.ts: realpathSync(process.argv[1]) — runtime entry path, not user input
  - apps/api/tests/broker/expand.test.ts: readFileSync of test fixture path — test-controlled
- pnpm lint exits 0 across both apps with --max-warnings 0
- 14 of 15 security rules remain active at error; no blanket file disables
2026-06-13 05:24:02 -04:00
Lucas Berger 89411ce44b style(09): prettier-format outboxWorker.test.ts (fix CI format check)
CI / changes (pull_request) Successful in 2s
CI / fast-checks (pull_request) Successful in 1m20s
CI / api (pull_request) Successful in 58s
CI / harness (pull_request) Successful in 3m52s
CI / gate (pull_request) Successful in 1s
2026-06-12 21:12:17 -04:00
Lucas Berger b724b3e932 fix(09): WR-03/IN-03/IN-04 add drain-listener teardown, test-only __resetDrainState, and remove stale RED @ts-ignore 2026-06-12 20:51:49 -04:00
Lucas Berger 2b113045f7 feat(09-01): add scheduleOutboxDrain, drainRequested, initOutboxTrigger; route setInterval through wrapper
- Add import { onOutboxDrain } from outboxTrigger.js
- Add let drainRequested = false (D-05 trailing-re-drain flag)
- Export scheduleOutboxDrain(): void — isDraining guard + drainRequested loop (D-05/T-09-01)
  drainRequested=false reset precedes recursive call (Pitfall 3)
  errors caught via .catch to prevent crash (D-02/T-09-03)
- Export initOutboxTrigger(): void — registers onOutboxDrain(() => scheduleOutboxDrain())
- startOutboxWorker setInterval body: scheduleOutboxDrain() replaces runOutboxDrain().catch()
  15 * 1000 interval unchanged (D-08)
- runOutboxDrain body/isDraining guard/finally unchanged (D-02/D-07)
- Fix trigger-wiring tests: add beforeAll(initOutboxTrigger) to wire EventEmitter listener;
  fix Test C mock to return empty rows on trailing drain (correct D-07 behaviour)
- 30/30 outboxWorker tests GREEN; tsc --noEmit clean
2026-06-12 16:52:36 -04:00
Lucas Berger bcde073729 test(09-01): add failing trigger-wiring tests for SC-1, D-05, D-07
- Import scheduleOutboxDrain (not yet exported — causes RED)
- Import signalOutboxDrain from outboxTrigger.ts
- Add describe block 'scheduleOutboxDrain — trigger wiring (D-09)' with 3 tests:
  Test A SC-1: signalOutboxDrain() fires drain promptly without timer advance
  Test B D-05: two mid-drain signals collapse to exactly one trailing re-drain
  Test C D-07: concurrent scheduleOutboxDrain() calls dispatch exactly once via isDraining guard
- 27 pre-existing tests unmodified and passing; 3 new tests failing (RED)
2026-06-12 16:48:41 -04:00
Lucas Berger 982438dc10 style(13-03): apply Prettier formatting across repo
Mechanical reformat — no logic changes. 398 files changed, 19125
insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc
(singleQuote:true, semi:true, tabWidth:2, trailingComma:all,
printWidth:100). Isolated per D-13-08 for reviewability.
2026-06-11 20:35:18 -04:00
Lucas Berger 03e953158a fix(13-02): eliminate all ESLint violations — pnpm lint exits 0
- eslint.config.js: disable React Compiler rules (v7 flat.recommended enables
  them; codebase does not use the Compiler); add e2e/ to disableTypeChecked
  block; promote exhaustive-deps to error
- API broker: remove redundant as-casts (outboxWorker, poller, reminderScheduler,
  expand, sync, vevent, spike); add targeted ical.js no-unsafe-assignment/argument
  disables with justifying comments inside try blocks
- API routes/sse.ts: fix no-misused-promises on async writeSSE callback with
  void+IIFE+catch pattern
- API routes/lists.ts: let → const for updateValues
- API tests: remove unused imports (beforeEach, eq, vi); rename unused vars
  with _ prefix; remove unused lastActiveId assignment
- PWA components: void navigate() and void queryClient.invalidateQueries() on
  all fire-and-forget call sites; fix CalendarShell explicit-type-casts;
  Couldn't → HTML entity
- PWA test files: as unknown as Response for partial mock objects; string | null
  type annotation on mockLastSyncedUid; remove async from test callbacks without
  await; act(() => {}) not await act(async () => {}) for sync ops
- sw.ts: restructure Notification.data?.url access as let+if so disable
  comments land on the exact violation lines; void self.skipWaiting()
2026-06-11 20:23:38 -04:00
Lucas Berger 19d92c671b test(260610-hbu): drop unused 'now' param in rowForNow helper 2026-06-10 12:38:37 -04:00
Lucas Berger 93bb2c1c68 test(260610-hbu-02): update reminderScheduler tests for catch-up + per-uid dedup
- Add SINGLE-FIRE: 3 consecutive ticks, exactly 1 dispatch total
- Add MISSED-TICK-RECOVERY: fires at 8-min lead when ideal 15-min tick skipped
- Add ALREADY-STARTED: dtstart<=now returns no rows, 0 dispatches
- Add CR-01 pruning: started-event entry pruned after dtstart passes
- Add D-16: empty subscriptions, zero sends, no crash
- Add T-05-19: per-sub error isolation, both subs attempted when first throws
- Add fan-out: 2 subs -> 2 dispatches for one event
- Rewrite WR-01 test to per-uid dedup language; remove minuteBucket tests
- Update file docblock for catch-up (now, now+16min] window and per-uid dedup
2026-06-10 12:36:23 -04:00
Lucas Berger 593302ee41 test(06-03): add failing tests for hasRrule + bounded expansion
- Add hasRrule===true assertion for recurring events (weekly-dst.ics)
- Add hasRrule===false assertion for non-recurring events (single-duration.ics)
- Add weekly-count3.ics fixture (FREQ=WEEKLY;COUNT=3, 1-hour events)
- Add bounded RRULE test: expects exactly 3 occurrences in wide window
- Add per-occurrence duration test: each occurrence is 1 hour (not recurrence span)
- Tests are RED: hasRrule field absent from CalendarOccurrence interface
2026-06-10 11:11:08 -04:00
Lucas Berger a59455a727 test(06-02): add failing tests for RRULE UNTIL/COUNT + FREQ persistence
- vevent.test.ts: add COUNT, UNTIL-DATE, UNTIL-DATETIME serialization assertions
- outboxWorker.test.ts: add assembleRruleString (D-06) describe block (not yet exported)
- outboxWorker.test.ts: add FREQ persistence (D-07 regression) describe block
- RED: assembleRruleString not yet exported; FREQ-persistence cases fail on missing helper
2026-06-10 10:56:22 -04:00
Lucas Berger 17756fc523 fix(05-review): NEW-WR-01 emit delete changes when server returns zero events (whole-cache clear)
Pre-capture all currently-cached rows into pendingDeleteRows before the whole-cache
db.delete() when seenUids.length === 0. The existing >0 branch behavior is unchanged.
Adds a regression test verifying onChanges receives one delete change per cached row
on a full-calendar clear.
2026-06-09 22:39:11 -04:00
Lucas Berger 1044de57ae test(05-review): add CR-01/WR-01 reminder pruning tests and IN-01 actor-name tests 2026-06-09 22:30:03 -04:00
Lucas Berger ef558b65be test(05-01): add Wave-0 RED scaffolds + VAPID fixture + setup truncation
- tests/fixtures/vapid.ts: static TEST_VAPID keypair for offline unit tests
- tests/lib/pushDispatcher.test.ts: RED — 410/404 prune + 201/5xx no-delete
- tests/lib/pushCoalescer.test.ts: RED — burst coalesce fires once with count=N; excludeUserId
- tests/broker/reminderScheduler.test.ts: RED — shared+timed filter; dedup by (uid,minuteBucket)
- tests/lib/eventChangeDispatcher.test.ts: RED — create/meaningful-update fires; description-only silent; actor excluded
- tests/routes/push.test.ts: RED — POST 201/401; DELETE removes rows; GET vapid-public-key
- test/setup.ts: import pushSubscriptions + add db.delete(pushSubscriptions) in afterEach
- all 5 RED files fail on missing-module (correct; implementations in Plans 05-02..05-06)
2026-06-09 20:50:35 -04:00
Lucas Berger b8c186491b fix(03): IN-03 re-validate outbox payload before VEVENT build, hard-fail invalid rows 2026-06-09 11:05:50 -04:00
Lucas Berger 5168920eb1 fix(03): CR-01 preserve RRULE on edit-as-move (forward source rule to create row) 2026-06-09 10:59:19 -04:00
Lucas Berger 7a48659cae fix(03): update event lookup test mocks for CR-01/CR-02 query-chain changes
The CR-01 fix appended .orderBy().limit(1) to the edit/delete event lookups
and CR-02 added .innerJoin(calendars).limit(1) to the freshest-etag re-read.
The existing test doubles terminated the mock chain at .where(), so the new
chain calls hit undefined methods → handlers caught the throw and returned 503
(events.test.ts) and the worker skipped the PUT (outboxWorker.test.ts).

Extend the mocks to match the corrected production chains. Behaviour-preserving:
mockWhereCalEvents stays the awaited terminal so etag override assertions still drive.

8 failing tests now green; full suite: api 103, pwa 141.
2026-06-09 10:51:10 -04:00
Lucas Berger 98753d8e34 fix(broker): reconcile deletes into cache + resync before marking outbox done
Two write-path cache bugs surfaced during Gate 2 live testing:

P1 (delete didn't work / ghost event): syncCalendar only UPSERTED events
present on Fastmail and never removed cache rows for events that disappeared.
A successful CalDAV delete left the row in calendar_events forever, so
GET /api/events kept returning it and the UI showed a ghost that 'wouldn't
delete' (even after refresh). Add a prune step: delete calendar_events rows for
this calendar whose uid is absent from the server response (scoped to cal.id so
it never touches another calendar or the other member's rows — BUG B). Empty
server result prunes the whole calendar's cache.

P2 (edit needed a manual refresh): the outbox worker marked a row 'done' BEFORE
triggerTargetedResync refreshed the cache. The PWA's SyncStateToast invalidates
['events'] the instant sync-status flips to 'done', so it refetched stale cache.
Re-sync first, then mark done — 'done' now guarantees the cache reflects the write.

Tests: +2 prune regressions (present-subset prune, empty-server prune-all).
2026-06-07 16:12:43 -04:00
Lucas Berger a9d3de658e fix(03): correct event-write timezone + per-user calendar identity (Gate 2 Part D)
BUG A — timed events written 4h off: EventForm sent a naive local wall-clock
string with no offset; the UTC API container parsed it via new Date() as UTC, so
09:00 America/Toronto serialized to DTSTART:...090000Z. Fix: new
apps/pwa/src/lib/eventDateTime.ts serializes timed events to an unambiguous UTC
instant in the browser (where the operator's zone is known); all-day stays a DATE
string. No backend change.

BUG B — created events attached to the wrong user's calendar + duplicate calendar
rows per poll: calendars had no unique key on url, and poller/sync matched
calendars by url alone — so under the shared single Fastmail account (D-16) one
member's collection resolved to the other member's row. Fix: composite
unique(user_id, url); scope poller lookup + sync select to (userId, url); hand
migration 0001 (dedup + add key), applied to the live DB.

Regression tests fail against the buggy url-only predicate. API 98/98, PWA 140/140,
tsc clean both packages.
2026-06-06 22:32:10 -04:00
Lucas Berger 5eb26c0e6b test(03-11): RED — fresh etag re-read before PUT to avoid spurious 412 (WR-02)
- WR-02 fresh: update PUT must use calendarEvents.etag not stale enqueue-time etag
  (fails RED: capturedEtag === 'old-etag', not 'new-etag')
- WR-02 fallback: when calendarEvents has no row, fall back to row.etag (passes in RED)
- Add mockWhereCalEvents to mock infrastructure to isolate calendarEvents selects
- Switch all beforeEach to vi.resetAllMocks() to prevent mockImplementationOnce bleed
2026-06-05 21:05:40 -04:00
Lucas Berger b409c09e25 feat(03-11): GREEN — durable create-before-delete gating + drain concurrency guard (CR-04, CR-05)
- CR-04: delete rows with groupId query DB for sibling create status before dispatch
  - sibling 'pending': defer delete to later cycle (leave row pending)
  - sibling 'failed'/'dead': mark delete failed permanently (original event preserved, D-04)
  - sibling 'done': dispatch delete normally
- CR-05: module-level isDraining guard; overlapping 15s cycles are no-ops
  - SINGLE-PROCESS ONLY — documented limitation for multi-replica deployments
- Fix mockFromFn to use Symbol.for('drizzle:Name') instead of JSON.stringify (circular)
- Update D-04 ordering test to queue sibling-status mock response
2026-06-05 21:01:57 -04:00
Lucas Berger 6b2cdf3683 test(03-11): RED — durable create-before-delete gating + concurrency guard (CR-04, CR-05)
- CR-04 cross-batch drain 1: sibling create 'pending' must block delete dispatch
- CR-04 cross-batch drain 2: sibling create 'done' must allow delete dispatch
- CR-04 paired-create-failed: sibling create 'failed'/'dead' marks delete failed, preserves original
- CR-05: two overlapping drain calls must invoke createCalendarEvent exactly once
2026-06-05 20:56:07 -04:00
Lucas Berger c178dcee0c test(03-10): add RED tests for CR-03 fail-closed creds + WR-01 backoff index
- Add mockDecryptPassword to vi.hoisted() so tests can control loadClientForUser behavior
- Add vi.mock for broker/crypto.js to enable CR-03 scenario
- Introduce wireMockChain() helper that differentiates credential vs outbox db selects
- CR-03 RED: credential-load failure must leave row pending, not call createFastmailClient('')
- WR-01 RED: first transient retry must use BACKOFF_SECONDS[0]=15s not BACKOFF_SECONDS[1]=60s
- Update FAKE_CRED_ROW so loadClientForUser can return a real credential-shaped row
2026-06-05 20:50:11 -04:00
Lucas Berger 813a7ba697 test(03-10): add RED tests for ICS builder wiring + WR-04 + CR-02
- vevent.test.ts: D-13 form-parsed contract block — timed and all-day cases
  (all-day DTEND+1 fails: emits 20260610 not 20260611)
- outboxWorker.test.ts: worker integration — create/update must pass BEGIN:VCALENDAR
  to CalDAV write functions (fails: raw JSON passes through today)
- worker: unparseable payload must mark row failed (fails: marks done today)
- Update makeRow default payload to form JSON shape the worker should parse
2026-06-05 20:46:50 -04:00
Lucas Berger cd4a8931e5 feat(03-04): implement outbox drain state machine (GREEN)
- runOutboxDrain: drains pending outbox rows, dispatches CalDAV writes
  via broker/write.ts, classifies HTTP responses per D-07/D-08
- CONFLICT_STATUS=412 routes to conflict flow: mark failed, re-sync (D-08)
- TRANSIENT_STATUSES: exponential backoff with MAX_ATTEMPTS=5 dead-letter (D-07)
- HARD_FAIL_STATUSES 400/401/403: fail immediately, no retry (D-07)
- Edit-as-move D-04: create row sorted before delete for same groupId;
  create-fail aborts the paired delete (T-03-14)
- triggerTargetedResync: fetches fresh DAVCalendars, calls syncCalendar (D-06)
- startOutboxWorker: node-cron */15 * * * * * schedule (15s interval)
- Fix test scaffold: vi.hoisted() for mock variables to resolve vitest
  hoisting TDZ issue; simplified mock chain to match and() single .where()
2026-06-05 18:20:18 -04:00
Lucas Berger bbfccda756 test(03-01): add Wave 0 RED test scaffold for all Phase 3 behaviors
- vevent.test.ts: DTSTART UTC 'Z' for timed, DATE for all-day (D-13), RRULE (CAL-04/07)
- write.test.ts: createCalendarEvent uid.ics filename, updateCalendarEvent/deleteCalendarEvent
  etag/If-Match shapes (CAL-04/05/06, D-08)
- outboxWorker.test.ts: pending→done on 204, pending→failed on 412 (no retry), pending→backoff
  on 500, pending→dead at MAX_ATTEMPTS, edit-as-move create-before-delete ordering (D-04/D-07/D-08)
- events.test.ts (extended): POST /create 202+outbox row, PATCH /edit 202+etag, DELETE /:uid 202,
  GET /sync-status, GET /writable-calendars D-03 access control, 403 unauthorized calendar (V4)
- InstallPrompt.test.tsx: isIOSSafariNonStandalone UA detection, useAndroidInstallPrompt
  canInstall lifecycle (PWA-01/PWA-02)
All tests fail RED — implementation modules do not exist yet
2026-06-05 17:26:02 -04:00
Lucas Berger 194f6a82a8 fix(02): show owner name / Family in event popover footer
Backend:
- expand.ts: add ownerName: string | null to CalendarOccurrence
  interface and expandOccurrences() signature; thread it onto every
  emitted occurrence.
- events.ts: SELECT users.displayName as ownerName in the join; pass
  it to expandOccurrences().

Frontend:
- client.ts: add ownerName: string | null to CalendarOccurrence.
- EventDetailPopover.tsx: render isShared ? 'Family' :
  (ownerName ?? calendarName) in the footer instead of calendarName.

Tests:
- expand.test.ts: pass ownerName to all expandOccurrences() calls;
  assert ownerName is carried onto occurrences in the DST test.
- events.test.ts: add ownerName to mock rows; assert ownerName present
  on occurrences; add ownerName assertion to timed-recurring test.
- EventDetailPopover.test.tsx: add ownerName to fixtures; split
  "calendar name in footer" into three targeted tests covering
  personal-with-owner, shared→Family, and null-owner fallback.
2026-06-05 15:14:43 -04:00
Lucas Berger f70496871a fix(02): populate hasRrule on every sync upsert so recurring masters are flagged
- Use ICAL.Event.isRecurring() (parity with expand.ts) to detect RRULE/RDATE
- Add hasRrule to .values() INSERT and .onDuplicateKeyUpdate() SET so the flag
  is set on first sync and self-heals on every subsequent re-sync
- Without this fix every event had has_rrule=0 (column default), causing the
  events route recurring-master pre-filter to return zero recurring occurrences
- Add sync.test.ts cases: hasRrule=true for timed+all-day recurring VEVENTs,
  hasRrule=false for non-recurring, and hasRrule in onDuplicateKeyUpdate.set
2026-06-05 14:50:18 -04:00
Lucas Berger ee2281fe81 fix(02): correct DURATION-only event end in expand.ts (BUG 1)
- Replace dtend ?? dtstart with event.endDate which handles DURATION-only VEVENTs
- Add positive-duration guard (PT30M / P1D) to both non-recurring and recurring branches
- Add single-duration.ics fixture and regression test asserting end > start for DURATION-only events
2026-06-05 14:33:00 -04:00
Lucas Berger 93c368402c fix(02): update expand tests to assert IANA-annotated format + add cross-contract test
- Assert timed start/end strings include '[America/New_York]' bracket (not offset-only)
- Assert DST boundary offsets: -05:00[America/New_York] pre-transition, -04:00[America/New_York] post
- Add cross-contract regression test: feeds expandOccurrences output directly into
  Temporal.ZonedDateTime.from() to prove the expand→hydrate contract holds end-to-end
- Import 'temporal-polyfill/global' at top of test file for the Temporal global
- Rename describe block from 'RED stubs (Wave 0)' to reflect GREEN state
2026-06-05 14:04:00 -04:00
Lucas Berger 75252eb08c feat(02-01): schema columns, PWA vitest harness, ICS fixtures, RED test stubs
- Add calendarEvents.hasRrule boolean + idx_calendar_events_has_rrule index (Phase 2 pre-filter)
- Add calendars.isShared boolean for shared-family calendar identification
- Create apps/pwa/vitest.config.ts with jsdom environment
- Add vitest, @testing-library/react, jsdom, @testing-library/jest-dom to PWA devDependencies
- Add "test": "vitest run" script to apps/pwa/package.json
- Create three ICS fixtures: weekly-dst.ics (DST spanning), allday-birthday.ics, exdate-series.ics
- Create RED test stub expand.test.ts with concrete DST wall-clock assertions (10:00 local both sides of March 2026 boundary)
- Create RED test stub events.test.ts with 400 validation and color/isShared field contracts
- Create RED test stub hydrateEvents.test.ts with Temporal type and calendarId routing contracts (shared→"shared", personal→String(ownerUserId))
- Create RED test stub calendarConfig.test.ts with firstDayOfWeek 0→7 translation contract
2026-06-05 09:29:45 -04:00
Lucas Berger 1b3ea4e99b test(01-03): add failing tests for broker poller ctag change detection
- 5 tests: skip on unchanged ctag, sync on changed ctag, first-sync (null ctag), decrypt failure handled, all credentials processed
- Mocks db.select, createFastmailClient, syncCalendar, decryptPassword at module level
- RED gate: all fail (src/broker/poller.ts does not exist yet)
2026-06-04 10:34:12 -04:00
Lucas Berger 90b99296e8 test(01-03): refine sync RED tests with richer db mock (select + insert)
- Use separate mock functions for insert/select chain to enable per-test assertions
- Reset mock implementations in beforeEach after vi.clearAllMocks
- Mock returns {id:42} from select (calendar ID for event upsert)
2026-06-04 10:30:53 -04:00
Lucas Berger ae21541e97 test(01-03): add failing tests for syncCalendar (D-13 all-day DATE handling)
- 6 tests: all-day DATE vs timed TIMESTAMP, allDay flag, upsert on dup UID, rawVevent blob, ctag update
- Mocks db singleton at module level with vi.mock hoisting pattern
- Uses SAMPLE_VEVENT_TIMED/ALLDAY fixtures from tests/helpers/db.ts
- RED gate: all fail (src/broker/sync.ts does not exist yet)
2026-06-04 10:29:29 -04:00