From 9fe56732515df90bb1d84c86ffef7977e0e74bb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 10 Jun 2026 16:18:26 +0200 Subject: [PATCH] fix(dashboard): one skeleton language and a single chart reveal on first load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/ops/intake-repair.md | 31 +- scripts/repair-intake-anomalies.ts | 625 ++++++++++++------ .../__tests__/dashboard-chart-reveal.test.tsx | 187 ++++++ .../dashboard-suspense-boundaries.test.ts | 15 +- .../dashboard-tile-strip-skeleton.test.ts | 16 +- src/app/page.tsx | 107 ++- src/components/charts/health-chart.tsx | 32 +- .../charts/medication-compliance-chart.tsx | 26 +- src/components/charts/mood-chart.tsx | 35 +- src/components/charts/trend-card-skeleton.tsx | 56 ++ src/components/dashboard/chart-reveal.tsx | 129 ++++ .../coach-panel/coach-conversation.tsx | 29 +- .../coach-panel/self-context-chips.tsx | 124 ++++ 13 files changed, 1166 insertions(+), 246 deletions(-) create mode 100644 src/app/__tests__/dashboard-chart-reveal.test.tsx create mode 100644 src/components/charts/trend-card-skeleton.tsx create mode 100644 src/components/dashboard/chart-reveal.tsx create mode 100644 src/components/insights/coach-panel/self-context-chips.tsx diff --git a/docs/ops/intake-repair.md b/docs/ops/intake-repair.md index c7c9e7099..1c09be209 100644 --- a/docs/ops/intake-repair.md +++ b/docs/ops/intake-repair.md @@ -31,15 +31,24 @@ the medication intake ledger (`medication_intake_events`): Always. The default mode only reports and never mutates: ```bash -# inside the app container (or any checkout with DATABASE_URL set) -pnpm dlx tsx scripts/repair-intake-anomalies.ts +# 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 tsx scripts/repair-intake-anomalies.ts --user +pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --user ``` -Use `pnpm dlx tsx`, not bare `pnpm tsx` — the production standalone image -strips `tsx`. `DATABASE_URL` must point at the target database. +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) and a table of implausible rows (id, medication, @@ -48,7 +57,7 @@ would be tombstoned) and a table of implausible rows (id, medication, ## Applying the fix ```bash -pnpm dlx tsx scripts/repair-intake-anomalies.ts --fix +pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix ``` `--fix` does three things: @@ -66,13 +75,17 @@ pnpm dlx tsx scripts/repair-intake-anomalies.ts --fix in explicitly: ```bash - pnpm dlx tsx scripts/repair-intake-anomalies.ts --fix --tombstone-implausible + pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix --tombstone-implausible ``` `--tombstone-implausible` is refused without `--fix`. - **Affected compliance rollups are recomputed** for every touched - `(user, medication, day)` through the shared rollup helper, so the - scheduled/taken counts and the rate self-correct immediately. + `(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 diff --git a/scripts/repair-intake-anomalies.ts b/scripts/repair-intake-anomalies.ts index 47efeadb7..b84f89852 100644 --- a/scripts/repair-intake-anomalies.ts +++ b/scripts/repair-intake-anomalies.ts @@ -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 # 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 + * + * # 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(); +// ───────────────────────────────────────────────────────────────────── +// 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 { - 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 { + 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 { 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 { (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 { 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(); + async function userTimezone(userId: string): Promise { + 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(); - // ─────────────────────────────────────────────────────────────────── - // 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 { 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 { 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); +}); diff --git a/src/app/__tests__/dashboard-chart-reveal.test.tsx b/src/app/__tests__/dashboard-chart-reveal.test.tsx new file mode 100644 index 000000000..41504b2f7 --- /dev/null +++ b/src/app/__tests__/dashboard-chart-reveal.test.tsx @@ -0,0 +1,187 @@ +/** + * v1.16.0 — dashboard first-load choreography. + * + * Two complaints from the maintainer's first-load walk-through: + * + * 1. The tile strip painted EMPTY dark cards while the primary query + * was in flight — no inner structure previewing the final tile. + * 2. The chart row popped in one chart at a time: every chart owns + * its own query, `/api/mood/analytics` is a single cheap read, so + * the mood chart reliably painted first and the measurement + * charts trickled in afterwards. + * + * The fix: structured `` silhouettes for every tile + * slot, plus a shared reveal gate (`useDashboardChartReveal` / + * ``) that holds every gated chart on its + * layout-stable skeleton until every chart's data settled — or the 2 s + * fallback fires so one slow widget cannot block the row. + * + * Project convention is SSR-only tests (vitest `node` environment, no + * DOM test runner): the gate logic is pinned through the pure + * `resolveChartRevealState` resolver, the markup through + * `renderToStaticMarkup`, and the page wiring through textual pins. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { + CHART_REVEAL_TIMEOUT_MS, + DashboardChartCell, + resolveChartRevealState, +} from "@/components/dashboard/chart-reveal"; +import { TrendCardSkeleton } from "@/components/charts/trend-card-skeleton"; +import { I18nProvider } from "@/lib/i18n/context"; + +const PAGE_PATH = join(process.cwd(), "src/app/page.tsx"); + +describe("resolveChartRevealState", () => { + it("stays hidden while the gate is not armed (no visible charts yet)", () => { + expect( + resolveChartRevealState({ + expectedIds: [], + readyIds: new Set(), + timedOut: false, + }), + ).toBe(false); + // Even a stale timeout cannot reveal an empty row. + expect( + resolveChartRevealState({ + expectedIds: [], + readyIds: new Set(), + timedOut: true, + }), + ).toBe(false); + }); + + it("holds the skeletons while any gated chart is still loading", () => { + expect( + resolveChartRevealState({ + expectedIds: ["weight-chart", "mood-chart"], + readyIds: new Set(["mood-chart"]), + timedOut: false, + }), + ).toBe(false); + }); + + it("reveals once every gated chart reported ready (the Promise.all moment)", () => { + expect( + resolveChartRevealState({ + expectedIds: ["weight-chart", "mood-chart"], + readyIds: new Set(["weight-chart", "mood-chart"]), + timedOut: false, + }), + ).toBe(true); + }); + + it("reveals on timeout so a slow widget cannot block the row", () => { + expect( + resolveChartRevealState({ + expectedIds: ["weight-chart", "mood-chart"], + readyIds: new Set(), + timedOut: true, + }), + ).toBe(true); + }); + + it("pins the 2 s fallback budget", () => { + expect(CHART_REVEAL_TIMEOUT_MS).toBe(2_000); + }); +}); + +describe("DashboardChartCell", () => { + const render = (revealed: boolean) => + renderToStaticMarkup( + + +
chart body
+
+
, + ); + + it("hidden state: keeps the chart mounted but invisible under a skeleton overlay", () => { + const html = render(false); + expect(html).toContain('data-revealed="false"'); + // Chart stays mounted (its query must fire) but is layout-only. + expect(html).toContain('data-slot="probe-chart"'); + expect(html).toContain("invisible"); + expect(html).toContain('aria-hidden="true"'); + // Layout-stable skeleton overlays the same box. + expect(html).toContain('data-slot="chart-skeleton"'); + expect(html).toContain("absolute inset-0"); + }); + + it("revealed state: drops the skeleton and fades the content in (motion-safe)", () => { + const html = render(true); + expect(html).toContain('data-revealed="true"'); + expect(html).not.toContain('data-slot="chart-skeleton"'); + expect(html).not.toContain("invisible"); + expect(html).toContain("motion-safe:animate-in"); + expect(html).toContain("motion-safe:fade-in"); + }); +}); + +describe("TrendCardSkeleton", () => { + it("renders a structured silhouette, not an empty card", () => { + const html = renderToStaticMarkup(); + expect(html).toContain('data-slot="trend-card-skeleton"'); + expect(html).toContain('aria-hidden="true"'); + // Structured: heading row + headline value + sub-row blocks. + const skeletonBlocks = html.match(/data-slot="skeleton"/g) ?? []; + expect(skeletonBlocks.length).toBeGreaterThanOrEqual(5); + // Same outer chrome + min-height floor as the real TrendCard slot. + expect(html).toContain("rounded-xl"); + expect(html).toContain("min-h-[8rem]"); + // Reduced motion honoured via the Skeleton primitive. + expect(html).toContain("motion-reduce:animate-none"); + }); +}); + +describe("dashboard chart-reveal wiring (page.tsx)", () => { + const src = readFileSync(PAGE_PATH, "utf8"); + + it("derives the gated id list from the chart visibility gates", () => { + expect(src).toMatch( + /const\s+revealChartIds:\s*string\[\]\s*=\s*\[\];/, + ); + expect(src).toMatch( + /useDashboardChartReveal\(revealChartIds\)/, + ); + }); + + it("every data-backed chart reports through onDataReady", () => { + for (const id of [ + "weight-chart", + "bmi-chart", + "bp-chart", + "pulse-chart", + "bodyFat-chart", + "mood-chart", + "sleep-chart", + "steps-chart", + "medications", + ]) { + expect(src).toContain(`onDataReady={() => markChartReady("${id}")}`); + } + }); + + it("gated entries render inside DashboardChartCell", () => { + expect(src).toMatch( + /entry\.revealGated\s*\?[\s\S]*?/, + ); + }); + + it("self-skeletoning cards (achievements / workouts) stay outside the gate", () => { + // Their entries never set `revealGated`, so the ungated branch + // renders them directly. + const achievements = src.match( + /charts\.push\(\{\s*id:\s*"achievements"[\s\S]*?\}\);/, + ); + const workouts = src.match( + /charts\.push\(\{\s*id:\s*"recentWorkouts"[\s\S]*?\}\);/, + ); + expect(achievements?.[0]).not.toContain("revealGated"); + expect(workouts?.[0]).not.toContain("revealGated"); + }); +}); diff --git a/src/app/__tests__/dashboard-suspense-boundaries.test.ts b/src/app/__tests__/dashboard-suspense-boundaries.test.ts index aa1a62d25..9db552cbe 100644 --- a/src/app/__tests__/dashboard-suspense-boundaries.test.ts +++ b/src/app/__tests__/dashboard-suspense-boundaries.test.ts @@ -50,13 +50,16 @@ describe("v1.4.40 — dashboard per-cell Suspense boundaries", () => { it("wraps each tile-strip cell in a `` boundary with a layout-stable placeholder", () => { const src = load(PAGE_PATH); // v1.4.41 W-FRONTEND-FACTORY (UX M1) swapped the prior `null` - // fallback for an `aria-hidden` placeholder div that mirrors the - // trend-card chrome. The tile body is synchronous today so the - // fallback rarely paints, but a future RSC hoist of any tile slot - // would otherwise leave the grid track empty and trigger CLS as - // the cell paints in. The structural pin guards the contract. + // fallback for an `aria-hidden` placeholder div; v1.16.0 upgraded + // it to the structured `` silhouette (the + // aria-hidden + trend-card chrome now live inside the component, + // see `trend-card-skeleton.tsx`). The tile body is synchronous + // today so the fallback rarely paints, but a future RSC hoist of + // any tile slot would otherwise leave the grid track empty and + // trigger CLS as the cell paints in. The structural pin guards the + // contract. expect(src).toMatch( - /\s*\{entry\.node\}\s*<\/Suspense>/, + /\}>\s*\{entry\.node\}\s*<\/Suspense>/, ); }); diff --git a/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts b/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts index 75ca1a0b1..3bf6a6583 100644 --- a/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts +++ b/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts @@ -130,7 +130,12 @@ describe("v1.4.43 W11 — dashboard tile-strip skeleton (wiring)", () => { ); }); - it("marks the skeleton aria-hidden + carries motion-reduce", () => { + it("marks the skeleton aria-hidden + renders structured silhouettes", () => { + // v1.16.0 — the strip silhouettes are structured `` + // cards (label + headline value + sub-row), not empty pulsing divs. + // Reduced motion moved into the component (the `` primitive + // carries `motion-reduce:animate-none`); the per-component test in + // `dashboard-chart-reveal.test.tsx` pins that. const src = readFileSync(PAGE_PATH, "utf8"); const skeletonBlock = src.match( /showTileStripSkeleton\s*&&[\s\S]*?<\/div>\s*\)\}/, @@ -138,7 +143,14 @@ describe("v1.4.43 W11 — dashboard tile-strip skeleton (wiring)", () => { expect(skeletonBlock).not.toBeNull(); const block = skeletonBlock?.[0] ?? ""; expect(block).toContain('aria-hidden="true"'); - expect(block).toContain("motion-reduce:animate-none"); + expect(block).toContain(" { + const src = readFileSync(PAGE_PATH, "utf8"); + expect(src).toMatch( + /\}>/, + ); }); it("gates the EmptyState on the resolved `showEmptyState` flag", () => { diff --git a/src/app/page.tsx b/src/app/page.tsx index 682319fb9..b9b3f0f55 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -53,7 +53,12 @@ import { import { MeasurementForm } from "@/components/measurements/measurement-form"; import { MoodForm } from "@/components/mood/mood-form"; import { MedicationIntakeQuickAdd } from "@/components/dashboard/medication-intake-quick-add"; +import { + DashboardChartCell, + useDashboardChartReveal, +} from "@/components/dashboard/chart-reveal"; import { TrendCard } from "@/components/charts/trend-card"; +import { TrendCardSkeleton } from "@/components/charts/trend-card-skeleton"; import { TrendHint } from "@/components/charts/trend-hint"; import { summaryToTrend7Delta } from "@/lib/analytics/trend-delta"; import { GettingStartedChecklist } from "@/components/onboarding/getting-started-checklist"; @@ -675,6 +680,31 @@ export default function DashboardPage() { const showRecentWorkoutsTile = layoutResolved && isChartVisible("recentWorkouts"); + // v1.16.0 — shared reveal gate for the chart row. Every data-backed + // chart mounts as soon as its visibility gate flips (so the per-chart + // queries fan out in parallel), but the cells hold their layout-stable + // skeletons until EVERY gated chart reported its data settled — or the + // 2 s timeout fires so one slow widget cannot block the row (see + // `chart-reveal.tsx`). Pre-fix the cheap `/api/mood/analytics` read + // made the mood chart paint first and the measurement charts trickle + // in one after another. The id list mirrors the `charts[]` entry ids + // below; the achievements + recent-workouts cards stay outside the + // gate (they self-skeleton and carry no chart-shaped footprint). + const revealChartIds: string[] = []; + if (showWeightChart) { + revealChartIds.push("weight-chart"); + if (user?.heightCm) revealChartIds.push("bmi-chart"); + } + if (showBpCharts) revealChartIds.push("bp-chart"); + if (showPulseChart) revealChartIds.push("pulse-chart"); + if (showBodyFatChart) revealChartIds.push("bodyFat-chart"); + if (showMoodChart) revealChartIds.push("mood-chart"); + if (showSleepChart) revealChartIds.push("sleep-chart"); + if (showStepsChart) revealChartIds.push("steps-chart"); + if (showMedicationsCard) revealChartIds.push("medications"); + const { revealed: chartsRevealed, markReady: markChartReady } = + useDashboardChartReveal(revealChartIds); + // Glucose widget — visible iff layout enables it AND at least one reading exists. // Glucose has no separate chart slot today, so the tile flag is the // single source of truth for it. @@ -1408,6 +1438,14 @@ export default function DashboardPage() { * undefined disables the hint (e.g. medications card). */ count?: number; + /** + * v1.16.0 — entry participates in the shared chart reveal: + * the cell holds its skeleton until every gated chart's data + * settled (or the 2 s fallback fires). Data-backed charts set + * this; the self-skeletoning achievements / recent-workouts + * cards do not. + */ + revealGated?: boolean; }; const charts: ChartEntry[] = []; if (showWeightChart) { @@ -1415,9 +1453,11 @@ export default function DashboardPage() { id: "weight-chart", order: widgetOrder("weight"), count: w?.count ?? 0, + revealGated: true, node: ( markChartReady("weight-chart")} chartKey="weight" types={["WEIGHT"]} title={t("dashboard.weight")} @@ -1434,9 +1474,11 @@ export default function DashboardPage() { charts.push({ id: "bmi-chart", order: widgetOrder("weight") + 0.5, + revealGated: true, node: ( markChartReady("bmi-chart")} chartKey="bmi" types={["WEIGHT"]} title={t("targets.bmi")} @@ -1461,9 +1503,11 @@ export default function DashboardPage() { id: "bp-chart", order: widgetOrder("bp"), count: Math.max(sys?.count ?? 0, dia?.count ?? 0), + revealGated: true, node: ( markChartReady("bp-chart")} chartKey="bp" types={["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]} title={t("dashboard.bloodPressure")} @@ -1483,9 +1527,11 @@ export default function DashboardPage() { id: "pulse-chart", order: widgetOrder("pulse"), count: p?.count ?? 0, + revealGated: true, node: ( markChartReady("pulse-chart")} chartKey="pulse" // v1.15.12 A2 — chart the RESTING series against the // resting band when available; otherwise chart raw heart @@ -1507,9 +1553,11 @@ export default function DashboardPage() { id: "bodyFat-chart", order: widgetOrder("bodyFat"), count: bf?.count ?? 0, + revealGated: true, node: ( markChartReady("bodyFat-chart")} chartKey="bodyFat" types={["BODY_FAT"]} title={t("dashboard.bodyFat")} @@ -1527,9 +1575,11 @@ export default function DashboardPage() { id: "mood-chart", order: widgetOrder("mood"), count: moodSummary?.count ?? 0, + revealGated: true, node: ( markChartReady("mood-chart")} compareBaseline={compareBaseline} chartKey="mood" userTimezone={user?.timezone} @@ -1542,9 +1592,11 @@ export default function DashboardPage() { id: "sleep-chart", order: widgetOrder("sleep"), count: sleepSummary?.count ?? 0, + revealGated: true, node: ( markChartReady("sleep-chart")} chartKey="sleep" types={["SLEEP_DURATION"]} title={t("dashboard.sleep") ?? "Sleep"} @@ -1561,9 +1613,11 @@ export default function DashboardPage() { id: "steps-chart", order: widgetOrder("steps"), count: stepsSummary?.count ?? 0, + revealGated: true, node: ( markChartReady("steps-chart")} chartKey="steps" types={["ACTIVITY_STEPS"]} title={t("dashboard.steps") ?? "Steps"} @@ -1583,9 +1637,11 @@ export default function DashboardPage() { charts.push({ id: "medications", order: widgetOrder("medications"), + revealGated: true, node: ( markChartReady("medications")} compareBaseline={compareBaseline} userTimezone={user?.timezone} /> @@ -1729,11 +1785,14 @@ export default function DashboardPage() { "[grid-template-columns:repeat(auto-fit,minmax(min(100%,11rem),1fr))]", )} > + {/* v1.16.0 — structured silhouettes (label + headline + value + sub-row, see ``) instead + of the former EMPTY pulsing cards, so the first + paint previews the final tile shape. Reduced motion + is honoured inside the component via the Skeleton + primitive's motion-reduce:animate-none. */} {Array.from({ length: configuredTileCount }).map((_, idx) => ( -
+ ))}
)} @@ -1789,14 +1848,11 @@ export default function DashboardPage() { * * v1.4.40 W-RSC — boundary added; v1.4.41 W-FRONTEND-FACTORY — fallback hoisted to layout-stable placeholder. */} -