The detail page gains a dedicated API tab, the full-edit button becomes a lifecycle row that opens the editor, the danger zone drops its red wash in favour of the shared card chrome, and the card menu's edit entry leads to the detail page it describes. The inventory tab finally records stock — packs and pens with optional expiry, plus corrections — instead of pointing at an API. The card's next-dose line reads from the canonical band model instead of a stale schedule window, which also showed old dose times after a schedule change; the repair script learns to reconcile drifted windows and tombstone pending rows parked on abandoned anchors.
5.8 KiB
Medication intake repair
scripts/repair-intake-anomalies.ts repairs the known historic defects in
the medication intake ledger (medication_intake_events) and its schedule
metadata (medication_schedules):
- Duplicate dose-slot rows. More than one live row on the same exact
(user, medication, scheduled_for)tuple — cross-source duplicates the pre-v1.15.19 write path could mint (e.g. a pending REMINDER row plus a taken API row for the same slot). The duplicate inflates the per-day scheduled count and paints a phantom entry in the history view. - Implausible
taken_at. Live rows whosetaken_atlands more than 7 days beforescheduled_foror more than 1 day in the future — typically the residue of a mis-attributed edit before the v1.15.19taken_atvalidation landed. - Window / times drift (v1.16.1). Schedule rows whose legacy
window_start/window_endno longer contains the canonicaltimes_of_day— typically a degenerate07:00 / 07:00point window left behind by an old write path while the dose times moved on (e.g. to09:00 / 21:00). The dose-band model never reads the window oncetimes_of_dayexists, but the stale pair kept feeding every legacy read (the pre-v1.16.1 cards painted "next intake 07:00" from it). - Stale-anchor pending rows (v1.16.1). Live pending rows
(
taken_at IS NULL, not skipped, not auto-missed) whosescheduled_forwall-clock HH:mm — evaluated in the user's timezone — matches no current dose anchor of the medication (any schedule'stimes_of_dayentry;window_startfor legacy rows without times). These are reminder-minted slots on retired schedule times that linger as phantom open doses in the ledger.
When to use it
- After upgrading to v1.15.19+, when a user reports a compliance rate above 100 %, duplicate entries in the medication history, or a dose shown as taken days away from its slot.
- The compliance rollup counts DISTINCT slots since v1.15.19, so the percentages self-correct on recompute even without running this script. Running it additionally clears the phantom rows from the history view.
- The
intake-slot-dedupworker already collapses canonical-slot duplicates at boot and on a daily cron; this script is the manual, immediately-observable fallback and the only path that surfaces the implausible-taken_atrows.
Run a dry-run first
Always. The default mode only reports and never mutates:
# inside the app container (or any checkout) — DATABASE_URL must point
# at the target database
pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts
# scoped to one account
pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --user <userId>
The script is self-contained: its only import is pg and it reads
DATABASE_URL straight from the environment (no dotenv, no @/ path
alias, no src/ imports). That matters in the production container —
the standalone image ships no project node_modules, so the previous
revision died there with Cannot find module 'dotenv/config'. Inside
the image pg resolves via NODE_PATH=/opt/pg-boss/node_modules; in a
checkout it resolves from the project node_modules; the
--package pg --package tsx pins cover any environment with neither.
Use pnpm dlx, not bare pnpm tsx — the standalone image also strips
tsx.
The dry-run prints every duplicate group (which row would be kept, which
would be tombstoned), a table of implausible rows (id, medication,
scheduledFor, takenAt, source, timestamps), every schedule whose
window has drifted off its times_of_day (with the reconciled bounds it
would write), and a table of stale-anchor pending rows.
Applying the fix
pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix
--fix does five things:
-
Duplicate groups are collapsed to one winner per slot. Precedence: an actioned row (taken or skipped) beats a pending one; between two taken rows the earlier
taken_atwins; ties break deterministically oncreated_at, thenid. Losers are soft-deleted (deleted_atset,sync_versionincremented) — the same tombstone shape the delete route writes, so connected clients drop the rows on their next delta-sync. Nothing is ever hard-deleted. -
Implausible
taken_atrows are NOT touched. The recorded intent is unknowable, so the operator (or the user) corrects or deletes them in the medication history tab. To soft-delete them wholesale instead, opt in explicitly:pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix --tombstone-implausible--tombstone-implausibleis refused without--fix. -
Drifted schedule windows are reconciled to
min/max(times_of_day)so the legacy pair stops contradicting the canonical dose times. Overnight windows (window_end < window_start) encode a deliberate wrap and are never touched. -
Stale-anchor pending rows are tombstoned (same soft-delete shape as the duplicate losers). A medication without any derivable anchor (PRN-only / unscheduled) is skipped — there is nothing to compare against.
-
Affected compliance rollups are recomputed for every touched
(user, medication, day). The script executes the same DISTINCT-slot aggregation SQL the shared helper runs (a verbatim twin ofrecomputeMedicationComplianceForDayinsrc/lib/rollups/medication-compliance-rollups.ts— kept inline so the script needs no app imports), so the scheduled/taken counts and the rate self-correct immediately.
The script is idempotent: a second --fix run finds zero duplicate groups
and changes nothing. Exit code is 0 on success (including a clean
zero-findings run), non-zero on bad arguments or a fatal error.