Files
HealthLog/docs/ops/intake-repair.md
T
Marc-André Bombeck 258e2fd365 feat(medications): an api tab, a stockable inventory and a next-due that tells the truth
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.
2026-06-10 20:48:46 +02:00

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):

  1. 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.
  2. Implausible taken_at. Live rows whose taken_at lands more than 7 days before scheduled_for or more than 1 day in the future — typically the residue of a mis-attributed edit before the v1.15.19 taken_at validation landed.
  3. Window / times drift (v1.16.1). Schedule rows whose legacy window_start / window_end no longer contains the canonical times_of_day — typically a degenerate 07:00 / 07:00 point window left behind by an old write path while the dose times moved on (e.g. to 09:00 / 21:00). The dose-band model never reads the window once times_of_day exists, but the stale pair kept feeding every legacy read (the pre-v1.16.1 cards painted "next intake 07:00" from it).
  4. Stale-anchor pending rows (v1.16.1). Live pending rows (taken_at IS NULL, not skipped, not auto-missed) whose scheduled_for wall-clock HH:mm — evaluated in the user's timezone — matches no current dose anchor of the medication (any schedule's times_of_day entry; window_start for 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-dedup worker 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_at rows.

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_at wins; ties break deterministically on created_at, then id. Losers are soft-deleted (deleted_at set, sync_version incremented) — 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_at rows 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-implausible is 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 of recomputeMedicationComplianceForDay in src/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.