- Import sql from drizzle-orm in admin.ts
- Add onDuplicateKeyUpdate({ set: { value: sql\`value\` } }) to the
conditional INSERT in POST /config/timezone/seed so a concurrent seed
(or seed racing a PUT) cannot 500 on the app_config.key PK constraint
- Existing value is preserved per D-03 no-overwrite (no-op ODKU)
- seeded flag still reflects the pre-flight SELECT (winner: true, loser: false)
- Add tests: 403 access control, seeded:true on first seed, seeded:false
on second seed without throw (WR-02 idempotent race)
- reminderScheduler.ts: add import { getHouseholdTimezone } from '../lib/householdTimezone.js'
and replace bare process.env.TZ ?? Intl... at line 247 with await getHouseholdTimezone(db)
- outboxWorker.ts: add same import and replace BOTH bare tz lookups at the update-branch
(~line 501) and create-branch (~line 607) with await getHouseholdTimezone(db)
- D-05 satisfied: all three all-day sites now read from the single stored accessor
- D-06 satisfied: getHouseholdTimezone falls back to process.env.TZ → Intl when unset;
existing process.env.TZ-pinned tests pass unchanged
- D-07 satisfied: eventDateTime.ts and hydrateEvents.ts are not modified
- outboxWorker.test.ts: update wireMockChain() to handle app_config table with where().limit()
chain returning empty rows (D-06 fallback), so existing CAL-13 all-day test stays green
- reminderScheduler.test.ts: update mockTwoQueries to mock the new third db.select() call
(getHouseholdTimezone) returning no row (D-06 fallback), keeping all 37 existing tests green
- All 76 broker tests pass; tsc --noEmit clean
- Add appConfig + getHouseholdTimezone/isValidIanaTimezone imports to admin.ts
- Add timezoneSchema: z.object({ timezone: z.string().min(1).max(64).refine(isValidIanaTimezone) })
No noEchoHook — timezone strings are non-sensitive (T-18-06)
- GET /api/admin/config/timezone: returns { timezone, isExplicitlySet } using D-06 fallback
- PUT /api/admin/config/timezone: validates via timezoneSchema + upserts via onDuplicateKeyUpdate
- POST /api/admin/config/timezone/seed: SELECT-then-INSERT (no onDuplicateKeyUpdate) to enforce D-03 no-overwrite
- All three routes appended AFTER existing routes so line-41 requireAdmin covers them (T-18-03)
- All 25 admin.test.ts tests pass; 366/366 full API suite green; tsc --noEmit clean
- getHouseholdTimezone(db): selects household_timezone from app_config
- D-06 fallback chain: stored value → process.env.TZ → Intl.DateTimeFormat().resolvedOptions().timeZone
- isValidIanaTimezone: try/catch Intl.DateTimeFormat (no Intl.supportedValuesOf per RESEARCH Pitfall 2)
- Exports match D-05 single-accessor contract for reminderScheduler + outboxWorker
- All 11 unit tests pass; 358/358 total suite green; tsc --noEmit clean
- 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
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.
- 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).
- 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.
- 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
- 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
- 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
- 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)
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).
- 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)
- 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
- 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)
- 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)
- 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
- Create apps/api/src/lib/bootGuards.ts with assertNotDevBypassInProduction()
- Guard exits non-zero when NODE_ENV=production AND DEV_AUTH_BYPASS=true (D-08)
- Wire import + call as first statement in isMainModule() block in index.ts
- 3/3 unit tests pass, typecheck green
- extend outboxWorker import to include initOutboxTrigger alongside startOutboxWorker
- call initOutboxTrigger() immediately after startOutboxWorker() inside isMainModule() guard
- gated by isMainModule() so tests importing app never register the drain listener (Pitfall 4)
- import signalOutboxDrain from '../lib/outboxTrigger.js'
- Site 1 (POST /create): fire-and-forget signal after outbox insert, before 202
- Site 2 (PATCH edit-as-move): signal after await db.transaction() resolves, not inside callback (D-03)
- Site 3 (PATCH same-calendar update): signal after outbox insert, before 202
- Site 4 (DELETE): signal after outbox insert, before 202
- 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()
0000_easy_slipstream already created lists/list_shares/list_items and the
calendars unique constraint, but 0001_lists_schema re-created those tables and
0001_calendars_user_url_unique was an orphan (not in _journal) — so a cold
`drizzle-kit migrate` against an empty DB failed with ERROR 1050 'Table lists
already exists'. Dev only survived because its DB was built incrementally; CI
is the first cold migrate and exposed it.
Regenerated a single 0000_baseline.sql from schema.ts. Verified on a fresh
mariadb:11: migrate succeeds, schema is structurally identical to the running
dev DB, `drizzle-kit generate` reports no drift, and all 238 API tests pass.
Local dev DBs must be rebuilt (drop + db:migrate); no prod exists.
- Re-issues oidc-auth cookie with maxAge so PWA sessions survive close/reopen
- Guards on c.get('oidcAuthJwt'): only runs when @hono/oidc-auth set a valid session
- Falsy oidcAuthJwt falls straight through — no resurrection of deleted/absent cookies
- Cookie attrs mirror the library: httpOnly, secure, sameSite=Lax, conditional domain
- maxAge reads OIDC_AUTH_EXPIRES (default 86400s)
- Replace [now+14min, now+16min] window with (now, now+16min] catch-up
- Replace minuteBucket-keyed Set with uid-keyed Map for exactly-once dedup
- Lead-accurate body: 'Starts in N min' (Math.max(1, round(lead/60000)))
- CR-01 pruning: drop entries whose dtstart <= now (event started)
- WR-01 preserved: mark uid sent after all dispatches complete
- Drop gte import; add gt import from drizzle-orm
- Add hasRrule: boolean to CalendarOccurrence interface
- Capture isRecurring = event.isRecurring() once before the branch
- Set hasRrule: isRecurring in non-recurring push (always false)
- Set hasRrule: isRecurring in recurring push (always true)
- All 10 expand.test.ts tests pass (RED→GREEN)