Editing a schedule's times no longer rewrites the past. Each material change archives the outgoing cadence as a dated revision, and every band consumer — history, compliance, write attribution, the auto-miss cutoff — mints past periods from the schedule that was valid at the time, so a dose era taken at its then-correct times stays on time forever. The repair script can infer historical eras from consistent old anchors behind an explicit flag. The welcome tour scrolls its target into view, flips above it when space below runs out and falls back to a centred sheet on any viewport, so no step can strand its buttons below the fold. The avatar menu drops the about entry — that lives in settings.
7.0 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.
Era backfill (--backfill-eras, v1.16.3)
Schedule edits made before v1.16.3 replaced the schedule rows without archiving the old state, so history reads past days against the current times. Section 5 of the script infers the lost era from the recorded slot anchors:
- A medication qualifies when its anchor-shaped rows (user-tz
HH:mmon a 5-minute grid) deviate from the currenttimes_of_dayfor at least 7 consecutive recorded days before the current times first appear, and it has no existingmedication_schedule_revisionsrow (re-runs are idempotent). - The proposal is one revision row:
valid_from= the first deviating row's instant,valid_until= local midnight of the first day on the current times, payload = oneFREQ=DAILYschedule carrying the observed old times. - A
starts_onthat postdates the first recorded row is flagged and pulled back to that row's instant.
The default run only reports the proposals. Apply them explicitly:
pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --backfill-eras
Review the dry-run table first — the inference is a heuristic. A wrongly
created revision can be deleted from medication_schedule_revisions
without side effects (the read paths fall back to live-only minting).