fix(dashboard): one skeleton language and a single chart reveal on first load

First load showed bare dark tiles, then the mood chart finished alone
and the remaining charts popped in one after another. Every widget now
renders a structured silhouette of its final shape, charts mount in
parallel but stay behind height-stable skeletons until all visible
charts report ready — with a two-second fallback so one slow widget
cannot hold the page — and the reveal fades in once, without layout
shift. The intake repair script becomes self-contained on plain pg so
it actually runs inside the production container, with the runbook
updated to the working invocation.
This commit is contained in:
Marc-André Bombeck committed 2026-06-10 16:18:26 +02:00
1 parent 51dd614199
commit 9fe5673251
13 files changed
+1166 -246

No files matched your search

+433 -192
View File
@@ -7,8 +7,8 @@
* (cross-source duplicates the pre-v1.15.19 write path could mint).
* Repair: keep one winner per slot (H1 precedence, see `pickWinner`
* below) and soft-delete the losers — `deleted_at = now()`,
* `sync_version` incremented, `updated_at` bumped via `@updatedAt` —
* exactly the tombstone shape the per-event DELETE route and the
* `sync_version` incremented, `updated_at` bumped — exactly the
* tombstone shape the per-event DELETE route and the
* `intake-slot-dedup` worker write, so iOS delta-sync drops them.
*
* 2. IMPLAUSIBLE TAKEN_AT — live rows whose `taken_at` lands more than
@@ -19,33 +19,65 @@
* into soft-deleting them.
*
* After any mutation the affected `(user, medication, day)` compliance
* rollups are recomputed through the shared rollup helpers — no SQL is
* duplicated here. The rollup aggregates DISTINCT slots since v1.15.19,
* so a recompute alone already heals the counts; the tombstones
* additionally clear the phantom rows from the history view.
* rollups are recomputed with the SAME slot-level DISTINCT aggregation the
* shared helper runs — see `recomputeComplianceDay` below, a verbatim
* SQL twin of `recomputeMedicationComplianceForDay` in
* `src/lib/rollups/medication-compliance-rollups.ts`. The rollup
* aggregates DISTINCT slots since v1.15.19, so a recompute alone already
* heals the counts; the tombstones additionally clear the phantom rows
* from the history view.
*
* Default is a DRY-RUN that only reports. Idempotent: a second `--fix`
* run finds zero groups and changes nothing.
*
* SELF-CONTAINED by design (v1.16.0)
* ----------------------------------
* The production standalone image ships neither the project
* `node_modules` tree nor the `@/` path alias, so the previous
* `dotenv/config` + `@/lib/db` import chain died with "Cannot find
* module" inside the container. This script therefore depends on
* exactly ONE package — `pg` — and reads `DATABASE_URL` straight from
* the environment (no dotenv). Inside the production image `pg` resolves
* via `NODE_PATH=/opt/pg-boss/node_modules` (Dockerfile installs
* `pg@8.x` there); in a repo checkout it resolves from the project
* `node_modules`. The `pnpm dlx` invocation below supplies `tsx` (the
* standalone image strips it) and pins `pg` for any environment where
* neither resolution path exists.
*
* Usage
* -----
* pnpm dlx tsx scripts/repair-intake-anomalies.ts # dry-run
* pnpm dlx tsx scripts/repair-intake-anomalies.ts --user <id> # scoped dry-run
* pnpm dlx tsx scripts/repair-intake-anomalies.ts --fix
* pnpm dlx tsx scripts/repair-intake-anomalies.ts --fix --tombstone-implausible
* # dry-run (DATABASE_URL must point at the target database)
* pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts
*
* `DATABASE_URL` must point at the target database.
* # scoped dry-run
* pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --user <id>
*
* # apply
* pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix
* pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix --tombstone-implausible
*/
import "dotenv/config";
import { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import {
dayKeyForScheduledFor,
recomputeMedicationComplianceForDay,
} from "@/lib/rollups/medication-compliance-rollups";
import { Client, types } from "pg";
const TAG = "[repair-intake-anomalies]";
// Prisma stores `DateTime` columns as `timestamp(3)` WITHOUT time zone,
// carrying UTC wall-clock values. node-postgres would parse those as
// LOCAL time by default; this parser pins them back to UTC so every
// Date this script handles is the true instant. (OID 1114 = timestamp
// without time zone.)
types.setTypeParser(1114, (value: string) => new Date(`${value.replace(" ", "T")}Z`));
/**
* Serialise a Date for a `timestamp without time zone` comparison /
* equality parameter. The ISO string's `Z` suffix is ignored by the
* `::timestamp` cast, so the bound value is the UTC wall-clock — the
* exact representation the column stores. Millisecond-precise, so
* slot-equality (`scheduled_for = $n::timestamp`) round-trips exactly.
*/
function utcParam(date: Date): string {
return date.toISOString();
}
interface CliOptions {
fix: boolean;
tombstoneImplausible: boolean;
@@ -125,18 +157,178 @@ function pickWinner(rows: IntakeRow[]): IntakeRow {
})[0];
}
/** `userId -> timezone` cache for the rollup day-key derivation. */
const tzCache = new Map<string, string | null>();
// ─────────────────────────────────────────────────────────────────────
// Timezone / day-key helpers — self-contained twins of the app helpers
// the previous revision imported. Reference implementations:
// - `userDayKey` / `DEFAULT_TIMEZONE`: `src/lib/tz/format.ts`
// - `tzOffsetMinutes` / `startOfDayUtcInTz` / the DST-safe day-after
// boundary: `src/lib/rollups/medication-compliance-rollups.ts`
// ─────────────────────────────────────────────────────────────────────
async function userTimezone(userId: string): Promise<string | null> {
if (!tzCache.has(userId)) {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { timezone: true },
});
tzCache.set(userId, user?.timezone ?? null);
const DEFAULT_TIMEZONE = "Europe/Berlin";
function isValidTimezone(tz: string): boolean {
try {
new Intl.DateTimeFormat("en-US", { timeZone: tz });
return true;
} catch {
return false;
}
return tzCache.get(userId) ?? null;
}
function safeTimezone(tz: string | null | undefined): string {
if (tz && isValidTimezone(tz)) return tz;
return DEFAULT_TIMEZONE;
}
/** `YYYY-MM-DD` of the instant in the user's tz (en-CA = ISO order). */
function dayKeyForScheduledFor(
scheduledFor: Date,
tz: string | null | undefined,
): string {
return new Intl.DateTimeFormat("en-CA", {
timeZone: safeTimezone(tz),
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(scheduledFor);
}
/** UTC offset (minutes) of `tz` at the given instant; honours DST. */
function tzOffsetMinutes(date: Date, tz: string): number {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).formatToParts(date);
const get = (type: Intl.DateTimeFormatPartTypes): string =>
parts.find((p) => p.type === type)?.value ?? "0";
let hour = Number(get("hour"));
if (hour === 24) hour = 0;
const asIfUtc = Date.UTC(
Number(get("year")),
Number(get("month")) - 1,
Number(get("day")),
hour,
Number(get("minute")),
Number(get("second")),
);
return Math.round((asIfUtc - date.getTime()) / 60000);
}
/** UTC instant of local-midnight on `dayKey` in `tz` (2-pass for DST). */
function startOfDayUtcInTz(dayKey: string, tz: string): Date {
const [yearStr, monthStr, dayStr] = dayKey.split("-");
const year = Number(yearStr);
const month = Number(monthStr);
const day = Number(dayStr);
let guess = new Date(Date.UTC(year, month - 1, day, 0, 0, 0, 0));
for (let i = 0; i < 2; i++) {
const offsetMin = tzOffsetMinutes(guess, tz);
guess = new Date(
Date.UTC(year, month - 1, day, 0, 0, 0, 0) - offsetMin * 60_000,
);
}
return guess;
}
/**
* `[start, windowEnd)` UTC window of the local calendar day — the same
* DST-safe boundary derivation `recomputeMedicationComplianceForDay`
* runs (23 / 25-hour days fold correctly because the end anchors on
* the NEXT day's local midnight, not `start + 24 h`).
*/
function dayWindowUtc(dayKey: string, tz: string): { start: Date; end: Date } {
const start = startOfDayUtcInTz(dayKey, tz);
const fallbackEnd = new Date(start.getTime() + 86_400_000);
const [y, m, d] = dayKey.split("-").map(Number);
const probeNext = new Date(Date.UTC(y, m - 1, d, 12, 0, 0) + 86_400_000);
const nextDayKey = dayKeyForScheduledFor(probeNext, tz);
const dstSafeEnd = startOfDayUtcInTz(nextDayKey, tz);
return {
start,
end: dstSafeEnd.getTime() > start.getTime() ? dstSafeEnd : fallbackEnd,
};
}
/**
* Recompute one `(userId, medicationId, dayKey)` compliance-rollup row.
*
* VERBATIM SQL TWIN of `recomputeMedicationComplianceForDay` in
* `src/lib/rollups/medication-compliance-rollups.ts` — keep the two in
* sync when the rollup contract changes. v1.15.19 semantics: the inner
* CTE folds rows sharing one `scheduled_for` instant into one DISTINCT
* slot with taken-beats-skipped priority; the outer aggregate counts
* slots, not rows, so a duplicated slot can never inflate `scheduled`.
* The upsert re-aggregates inside the statement (atomic against
* concurrent app writers); the trailing DELETE clears the row when the
* day no longer holds any live event.
*/
async function recomputeComplianceDay(
client: Client,
userId: string,
medicationId: string,
dayKey: string,
tz: string | null | undefined,
): Promise<void> {
const { start, end } = dayWindowUtc(dayKey, safeTimezone(tz));
await client.query(
`
WITH slot AS (
SELECT
BOOL_OR("taken_at" IS NOT NULL AND NOT "skipped") AS slot_taken,
BOOL_OR("skipped") AS slot_skipped
FROM "medication_intake_events"
WHERE "user_id" = $1
AND "medication_id" = $2
AND "deleted_at" IS NULL
AND "scheduled_for" >= $4::timestamp
AND "scheduled_for" < $5::timestamp
GROUP BY "scheduled_for"
),
aggregate AS (
SELECT
COUNT(*)::int AS scheduled,
COALESCE(SUM(CASE WHEN "slot_taken" THEN 1 ELSE 0 END), 0)::int AS taken,
COALESCE(SUM(CASE WHEN NOT "slot_taken" AND "slot_skipped" THEN 1 ELSE 0 END), 0)::int AS skipped
FROM slot
)
INSERT INTO "medication_compliance_rollups"
("user_id", "medication_id", "day", "scheduled", "taken", "skipped", "computed_at")
SELECT $1, $2, $3, aggregate.scheduled, aggregate.taken, aggregate.skipped, NOW()
FROM aggregate
WHERE aggregate.scheduled > 0
ON CONFLICT ("user_id", "medication_id", "day") DO UPDATE
SET "scheduled" = EXCLUDED."scheduled",
"taken" = EXCLUDED."taken",
"skipped" = EXCLUDED."skipped",
"computed_at" = EXCLUDED."computed_at"
`,
[userId, medicationId, dayKey, utcParam(start), utcParam(end)],
);
await client.query(
`
DELETE FROM "medication_compliance_rollups"
WHERE "user_id" = $1
AND "medication_id" = $2
AND "day" = $3
AND NOT EXISTS (
SELECT 1
FROM "medication_intake_events"
WHERE "user_id" = $1
AND "medication_id" = $2
AND "deleted_at" IS NULL
AND "scheduled_for" >= $4::timestamp
AND "scheduled_for" < $5::timestamp
)
`,
[userId, medicationId, dayKey, utcParam(start), utcParam(end)],
);
}
interface Summary {
@@ -149,7 +341,10 @@ interface Summary {
async function main(): Promise<void> {
if (!process.env.DATABASE_URL) {
console.error(`${TAG} DATABASE_URL must be set`);
console.error(
`${TAG} DATABASE_URL must be set (no dotenv — export it or prefix ` +
`the command: DATABASE_URL='postgresql://…' pnpm dlx …)`,
);
process.exit(1);
}
@@ -167,6 +362,9 @@ async function main(): Promise<void> {
(opts.userId ? ` user=${opts.userId}` : " (all users)"),
);
const client = new Client({ connectionString: process.env.DATABASE_URL });
await client.connect();
const summary: Summary = {
duplicateGroups: 0,
rowsTombstoned: 0,
@@ -175,95 +373,129 @@ async function main(): Promise<void> {
rollupsRecomputed: 0,
};
const userFilter = opts.userId
? Prisma.sql`AND e."user_id" = ${opts.userId}`
: Prisma.empty;
// Optional per-user scope. The fragment is a fixed string with a
// positional placeholder — never user-input spliced.
const userFilter = opts.userId ? `AND e."user_id" = $1` : "";
const userParams = opts.userId ? [opts.userId] : [];
/** `userId -> timezone` cache for the rollup day-key derivation. */
const tzCache = new Map<string, string | null>();
async function userTimezone(userId: string): Promise<string | null> {
if (!tzCache.has(userId)) {
const res = await client.query<{ timezone: string | null }>(
`SELECT "timezone" FROM "users" WHERE "id" = $1`,
[userId],
);
tzCache.set(userId, res.rows[0]?.timezone ?? null);
}
return tzCache.get(userId) ?? null;
}
// `(userId, medicationId, dayKey)` tuples whose compliance rollup needs
// a recompute after the tombstones. Deduped so a busy day folds once.
const daysToRecompute = new Set<string>();
// ───────────────────────────────────────────────────────────────────
// 1. Slot duplicates — exact (user, medication, scheduled_for) tuples
// carrying more than one live row.
// ───────────────────────────────────────────────────────────────────
const groups = await prisma.$queryRaw<
Array<{
try {
// ─────────────────────────────────────────────────────────────────
// 1. Slot duplicates — exact (user, medication, scheduled_for)
// tuples carrying more than one live row.
// ─────────────────────────────────────────────────────────────────
const groups = await client.query<{
user_id: string;
medication_id: string;
scheduled_for: Date;
row_count: number;
}>
>`
SELECT e."user_id", e."medication_id", e."scheduled_for",
COUNT(*)::int AS row_count
FROM "medication_intake_events" e
WHERE e."deleted_at" IS NULL
${userFilter}
GROUP BY e."user_id", e."medication_id", e."scheduled_for"
HAVING COUNT(*) > 1
ORDER BY e."user_id", e."medication_id", e."scheduled_for"
`;
summary.duplicateGroups = groups.length;
console.log(`\n${TAG} 1) duplicate slot groups found: ${groups.length}`);
for (const group of groups) {
const rows = (await prisma.medicationIntakeEvent.findMany({
where: {
userId: group.user_id,
medicationId: group.medication_id,
scheduledFor: group.scheduled_for,
deletedAt: null,
},
select: {
id: true,
takenAt: true,
skipped: true,
source: true,
createdAt: true,
},
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
})) as IntakeRow[];
if (rows.length < 2) continue; // collapsed since the scan — nothing to do.
const winner = pickWinner(rows);
const losers = rows.filter((r) => r.id !== winner.id);
console.log(
` - user=${group.user_id} medication=${group.medication_id} ` +
`slot=${group.scheduled_for.toISOString()} rows=${rows.length} ` +
`keep=${winner.id} (${winner.source}, ` +
`${winner.takenAt ? `taken ${winner.takenAt.toISOString()}` : winner.skipped ? "skipped" : "pending"}) ` +
`tombstone=[${losers.map((r) => `${r.id} (${r.source})`).join(", ")}]`,
}>(
`
SELECT e."user_id", e."medication_id", e."scheduled_for",
COUNT(*)::int AS row_count
FROM "medication_intake_events" e
WHERE e."deleted_at" IS NULL
${userFilter}
GROUP BY e."user_id", e."medication_id", e."scheduled_for"
HAVING COUNT(*) > 1
ORDER BY e."user_id", e."medication_id", e."scheduled_for"
`,
userParams,
);
if (opts.fix) {
// Same tombstone the per-event DELETE route and the dedup worker
// write: soft-delete + syncVersion bump (the sync feed echoes a
// monotonic value so iOS drops the row); `updatedAt` bumps via the
// schema's `@updatedAt`. The `deletedAt: null` guard keeps the
// write idempotent against a concurrent collapse.
const res = await prisma.medicationIntakeEvent.updateMany({
where: { id: { in: losers.map((r) => r.id) }, deletedAt: null },
data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
});
summary.rowsTombstoned += res.count;
summary.duplicateGroups = groups.rows.length;
console.log(
`\n${TAG} 1) duplicate slot groups found: ${groups.rows.length}`,
);
const tz = await userTimezone(group.user_id);
const dayKey = dayKeyForScheduledFor(group.scheduled_for, tz);
daysToRecompute.add(
`${group.user_id}|${group.medication_id}|${dayKey}`,
for (const group of groups.rows) {
const rowsRes = await client.query<{
id: string;
taken_at: Date | null;
skipped: boolean;
source: string;
created_at: Date;
}>(
`
SELECT "id", "taken_at", "skipped", "source"::text AS source, "created_at"
FROM "medication_intake_events"
WHERE "user_id" = $1
AND "medication_id" = $2
AND "scheduled_for" = $3::timestamp
AND "deleted_at" IS NULL
ORDER BY "created_at" ASC, "id" ASC
`,
[group.user_id, group.medication_id, utcParam(group.scheduled_for)],
);
}
}
const rows: IntakeRow[] = rowsRes.rows.map((r) => ({
id: r.id,
takenAt: r.taken_at,
skipped: r.skipped,
source: r.source,
createdAt: r.created_at,
}));
if (rows.length < 2) continue; // collapsed since the scan — nothing to do.
// ───────────────────────────────────────────────────────────────────
// 2. Implausible taken_at — report only; tombstone needs explicit
// operator opt-in.
// ───────────────────────────────────────────────────────────────────
const implausible = await prisma.$queryRaw<
Array<{
const winner = pickWinner(rows);
const losers = rows.filter((r) => r.id !== winner.id);
console.log(
` - user=${group.user_id} medication=${group.medication_id} ` +
`slot=${group.scheduled_for.toISOString()} rows=${rows.length} ` +
`keep=${winner.id} (${winner.source}, ` +
`${winner.takenAt ? `taken ${winner.takenAt.toISOString()}` : winner.skipped ? "skipped" : "pending"}) ` +
`tombstone=[${losers.map((r) => `${r.id} (${r.source})`).join(", ")}]`,
);
if (opts.fix) {
// Same tombstone the per-event DELETE route and the dedup worker
// write: soft-delete + syncVersion bump (the sync feed echoes a
// monotonic value so iOS drops the row). Prisma's `@updatedAt`
// is client-side, so raw SQL bumps `updated_at` explicitly —
// the offline-sync keyset feed orders on it. The
// `deleted_at IS NULL` guard keeps the write idempotent against
// a concurrent collapse.
const res = await client.query(
`
UPDATE "medication_intake_events"
SET "deleted_at" = NOW(),
"sync_version" = "sync_version" + 1,
"updated_at" = NOW()
WHERE "id" = ANY($1) AND "deleted_at" IS NULL
`,
[losers.map((r) => r.id)],
);
summary.rowsTombstoned += res.rowCount ?? 0;
const tz = await userTimezone(group.user_id);
const dayKey = dayKeyForScheduledFor(group.scheduled_for, tz);
daysToRecompute.add(
`${group.user_id}|${group.medication_id}|${dayKey}`,
);
}
}
// ─────────────────────────────────────────────────────────────────
// 2. Implausible taken_at — report only; tombstone needs explicit
// operator opt-in.
// ─────────────────────────────────────────────────────────────────
const implausibleRes = await client.query<{
id: string;
user_id: string;
medication_id: string;
@@ -273,91 +505,104 @@ async function main(): Promise<void> {
source: string;
created_at: Date;
updated_at: Date;
}>
>`
SELECT e."id", e."user_id", e."medication_id",
m."name" AS medication_name,
e."scheduled_for", e."taken_at", e."source"::text AS source,
e."created_at", e."updated_at"
FROM "medication_intake_events" e
JOIN "medications" m ON m."id" = e."medication_id"
WHERE e."deleted_at" IS NULL
AND e."taken_at" IS NOT NULL
AND (e."taken_at" < e."scheduled_for" - interval '7 days'
OR e."taken_at" > now() + interval '1 day')
${userFilter}
ORDER BY e."user_id", e."taken_at"
`;
summary.implausibleRows = implausible.length;
console.log(`\n${TAG} 2) implausible taken_at rows found: ${implausible.length}`);
if (implausible.length > 0) {
console.table(
implausible.map((r) => ({
id: r.id,
medication: r.medication_name,
scheduledFor: r.scheduled_for.toISOString(),
takenAt: r.taken_at.toISOString(),
source: r.source,
createdAt: r.created_at.toISOString(),
updatedAt: r.updated_at.toISOString(),
})),
}>(
`
SELECT e."id", e."user_id", e."medication_id",
m."name" AS medication_name,
e."scheduled_for", e."taken_at", e."source"::text AS source,
e."created_at", e."updated_at"
FROM "medication_intake_events" e
JOIN "medications" m ON m."id" = e."medication_id"
WHERE e."deleted_at" IS NULL
AND e."taken_at" IS NOT NULL
AND (e."taken_at" < e."scheduled_for" - interval '7 days'
OR e."taken_at" > now() + interval '1 day')
${userFilter}
ORDER BY e."user_id", e."taken_at"
`,
userParams,
);
if (!(opts.fix && opts.tombstoneImplausible)) {
console.log(
`${TAG} these rows are NOT changed automatically — the recorded ` +
`intent is unknowable. Correct or delete them in the medication ` +
`history tab, or re-run with --fix --tombstone-implausible to ` +
`soft-delete them.`,
const implausible = implausibleRes.rows;
summary.implausibleRows = implausible.length;
console.log(
`\n${TAG} 2) implausible taken_at rows found: ${implausible.length}`,
);
if (implausible.length > 0) {
console.table(
implausible.map((r) => ({
id: r.id,
medication: r.medication_name,
scheduledFor: r.scheduled_for.toISOString(),
takenAt: r.taken_at.toISOString(),
source: r.source,
createdAt: r.created_at.toISOString(),
updatedAt: r.updated_at.toISOString(),
})),
);
}
}
if (opts.fix && opts.tombstoneImplausible && implausible.length > 0) {
const res = await prisma.medicationIntakeEvent.updateMany({
where: {
id: { in: implausible.map((r) => r.id) },
deletedAt: null,
},
data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
});
summary.implausibleTombstoned = res.count;
console.log(
`${TAG} tombstoned ${res.count} implausible row(s) (operator opt-in)`,
);
for (const row of implausible) {
const tz = await userTimezone(row.user_id);
const dayKey = dayKeyForScheduledFor(row.scheduled_for, tz);
daysToRecompute.add(`${row.user_id}|${row.medication_id}|${dayKey}`);
}
}
// ───────────────────────────────────────────────────────────────────
// 3. Recompute the affected compliance rollups so scheduled/taken
// counts self-correct. Shared helper — no SQL duplicated here.
// ───────────────────────────────────────────────────────────────────
if (opts.fix && daysToRecompute.size > 0) {
console.log(
`\n${TAG} 3) recomputing ${daysToRecompute.size} compliance rollup day(s)`,
);
for (const key of daysToRecompute) {
const [userId, medicationId, dayKey] = key.split("|");
try {
await recomputeMedicationComplianceForDay(
userId,
medicationId,
dayKey,
await userTimezone(userId),
);
summary.rollupsRecomputed += 1;
} catch (err) {
console.error(
`${TAG} rollup recompute failed for ${key}: ` +
`${err instanceof Error ? err.message : String(err)}`,
if (!(opts.fix && opts.tombstoneImplausible)) {
console.log(
`${TAG} these rows are NOT changed automatically — the recorded ` +
`intent is unknowable. Correct or delete them in the medication ` +
`history tab, or re-run with --fix --tombstone-implausible to ` +
`soft-delete them.`,
);
}
}
if (opts.fix && opts.tombstoneImplausible && implausible.length > 0) {
const res = await client.query(
`
UPDATE "medication_intake_events"
SET "deleted_at" = NOW(),
"sync_version" = "sync_version" + 1,
"updated_at" = NOW()
WHERE "id" = ANY($1) AND "deleted_at" IS NULL
`,
[implausible.map((r) => r.id)],
);
summary.implausibleTombstoned = res.rowCount ?? 0;
console.log(
`${TAG} tombstoned ${res.rowCount ?? 0} implausible row(s) (operator opt-in)`,
);
for (const row of implausible) {
const tz = await userTimezone(row.user_id);
const dayKey = dayKeyForScheduledFor(row.scheduled_for, tz);
daysToRecompute.add(`${row.user_id}|${row.medication_id}|${dayKey}`);
}
}
// ─────────────────────────────────────────────────────────────────
// 3. Recompute the affected compliance rollups so scheduled/taken
// counts self-correct. Same DISTINCT-slot SQL the shared helper
// runs (see `recomputeComplianceDay` above).
// ─────────────────────────────────────────────────────────────────
if (opts.fix && daysToRecompute.size > 0) {
console.log(
`\n${TAG} 3) recomputing ${daysToRecompute.size} compliance rollup day(s)`,
);
for (const key of daysToRecompute) {
const [userId, medicationId, dayKey] = key.split("|");
try {
await recomputeComplianceDay(
client,
userId,
medicationId,
dayKey,
await userTimezone(userId),
);
summary.rollupsRecomputed += 1;
} catch (err) {
console.error(
`${TAG} rollup recompute failed for ${key}: ` +
`${err instanceof Error ? err.message : String(err)}`,
);
}
}
}
} finally {
await client.end();
}
// ───────────────────────────────────────────────────────────────────
@@ -371,11 +616,7 @@ async function main(): Promise<void> {
console.log(` rollup days recomputed: ${summary.rollupsRecomputed}`);
}
main()
.catch((err) => {
console.error(`${TAG} fatal error:`, err);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
main().catch((err) => {
console.error(`${TAG} fatal error:`, err);
process.exit(1);
});