mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
1 parent
51dd614199
commit
9fe5673251
13 files changed
+1166
-246
No files matched your search
@@ -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 <userId>
|
||||
pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --user <userId>
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
+433
-192
@@ -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);
|
||||
});
|
||||
@@ -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 `<TrendCardSkeleton>` silhouettes for every tile
|
||||
* slot, plus a shared reveal gate (`useDashboardChartReveal` /
|
||||
* `<DashboardChartCell>`) 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(
|
||||
<I18nProvider initialLocale="en">
|
||||
<DashboardChartCell revealed={revealed}>
|
||||
<div data-slot="probe-chart">chart body</div>
|
||||
</DashboardChartCell>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
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(<TrendCardSkeleton />);
|
||||
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]*?<DashboardChartCell revealed=\{chartsRevealed\}>/,
|
||||
);
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -50,13 +50,16 @@ describe("v1.4.40 — dashboard per-cell Suspense boundaries", () => {
|
||||
it("wraps each tile-strip cell in a `<Suspense>` 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 `<TrendCardSkeleton>` 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(
|
||||
/<Suspense\s+fallback=\{[\s\S]*?aria-hidden="true"[\s\S]*?\}\s*>\s*\{entry\.node\}\s*<\/Suspense>/,
|
||||
/<Suspense\s+fallback=\{<TrendCardSkeleton\s*\/>\}>\s*\{entry\.node\}\s*<\/Suspense>/,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 `<TrendCardSkeleton>`
|
||||
// cards (label + headline value + sub-row), not empty pulsing divs.
|
||||
// Reduced motion moved into the component (the `<Skeleton>` 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("<TrendCardSkeleton");
|
||||
});
|
||||
|
||||
it("uses the structured silhouette as the per-tile Suspense fallback", () => {
|
||||
const src = readFileSync(PAGE_PATH, "utf8");
|
||||
expect(src).toMatch(
|
||||
/<Suspense fallback=\{<TrendCardSkeleton \/>\}>/,
|
||||
);
|
||||
});
|
||||
|
||||
it("gates the EmptyState on the resolved `showEmptyState` flag", () => {
|
||||
|
||||
+93
-14
@@ -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: (
|
||||
<HealthChartDynamic
|
||||
key="weight-chart"
|
||||
onDataReady={() => 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: (
|
||||
<HealthChartDynamic
|
||||
key="bmi-chart"
|
||||
onDataReady={() => 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: (
|
||||
<HealthChartDynamic
|
||||
key="bp-chart"
|
||||
onDataReady={() => 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: (
|
||||
<HealthChartDynamic
|
||||
key="pulse-chart"
|
||||
onDataReady={() => 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: (
|
||||
<HealthChartDynamic
|
||||
key="bodyFat-chart"
|
||||
onDataReady={() => 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: (
|
||||
<MoodChart
|
||||
key="mood-chart"
|
||||
onDataReady={() => 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: (
|
||||
<HealthChartDynamic
|
||||
key="sleep-chart"
|
||||
onDataReady={() => 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: (
|
||||
<HealthChartDynamic
|
||||
key="steps-chart"
|
||||
onDataReady={() => 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: (
|
||||
<MedicationComplianceChart
|
||||
key="medications"
|
||||
onDataReady={() => 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 `<TrendCardSkeleton>`) 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) => (
|
||||
<div
|
||||
key={`tile-skeleton-${idx}`}
|
||||
className="bg-card border-border min-h-[8rem] animate-pulse rounded-xl border p-4 motion-reduce:animate-none md:p-6"
|
||||
/>
|
||||
<TrendCardSkeleton key={`tile-skeleton-${idx}`} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -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.
|
||||
*/}
|
||||
<Suspense
|
||||
fallback={
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="bg-card border-border flex h-full min-h-[8rem] w-full min-w-0 flex-col overflow-hidden rounded-xl border p-4 md:p-6"
|
||||
/>
|
||||
}
|
||||
>
|
||||
{/* v1.16.0 — the fallback is the same structured
|
||||
silhouette the tile-strip skeleton paints, so a
|
||||
suspending tile slot previews the final shape
|
||||
instead of an empty card. */}
|
||||
<Suspense fallback={<TrendCardSkeleton />}>
|
||||
{entry.node}
|
||||
</Suspense>
|
||||
</div>
|
||||
@@ -1829,8 +1885,31 @@ export default function DashboardPage() {
|
||||
* legend, or a server-streamed sparkline) gets its own
|
||||
* fallback without re-architecting the row.
|
||||
*/}
|
||||
<Suspense fallback={<ChartSkeleton />}>{entry.node}</Suspense>
|
||||
{entry.count != null ? <TrendHint count={entry.count} /> : null}
|
||||
{entry.revealGated ? (
|
||||
/* v1.16.0 — gated cell: the chart mounts (its query
|
||||
fires) but stays visually on the layout-stable
|
||||
skeleton until the shared reveal flips — all gated
|
||||
charts swap in the same frame with one short
|
||||
fade-in (motion-safe). The TrendHint rides inside
|
||||
the cell so it appears with its chart. */
|
||||
<DashboardChartCell revealed={chartsRevealed}>
|
||||
<Suspense fallback={<ChartSkeleton />}>
|
||||
{entry.node}
|
||||
</Suspense>
|
||||
{entry.count != null ? (
|
||||
<TrendHint count={entry.count} />
|
||||
) : null}
|
||||
</DashboardChartCell>
|
||||
) : (
|
||||
<>
|
||||
<Suspense fallback={<ChartSkeleton />}>
|
||||
{entry.node}
|
||||
</Suspense>
|
||||
{entry.count != null ? (
|
||||
<TrendHint count={entry.count} />
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
ReferenceArea,
|
||||
ReferenceDot,
|
||||
} from "recharts";
|
||||
import { Loader2, SlidersHorizontal } from "lucide-react";
|
||||
import { SlidersHorizontal } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
useState,
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
useCallback,
|
||||
type ComponentType,
|
||||
} from "react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip";
|
||||
import { ChartEmptyState } from "./chart-empty-state";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
@@ -231,6 +232,15 @@ interface HealthChartProps {
|
||||
* thread the same glyph it hands the stat strip.
|
||||
*/
|
||||
titleIcon?: ComponentType<{ className?: string }>;
|
||||
/**
|
||||
* v1.16.0 — fires once the chart's data query has settled (initial
|
||||
* load finished, success or error). The dashboard's shared reveal
|
||||
* gate listens here so every chart cell swaps from skeleton to
|
||||
* content in one frame instead of popping in one after another.
|
||||
* Optional and repeat-safe: the dashboard's `markReady` is
|
||||
* idempotent; non-dashboard mounts simply omit it.
|
||||
*/
|
||||
onDataReady?: () => void;
|
||||
}
|
||||
|
||||
interface ChartDataPoint {
|
||||
@@ -535,6 +545,7 @@ export function HealthChart({
|
||||
valueScale = 1,
|
||||
onVisibleStats,
|
||||
titleIcon,
|
||||
onDataReady,
|
||||
}: HealthChartProps) {
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
const { t, locale } = useTranslations();
|
||||
@@ -879,6 +890,15 @@ export function HealthChart({
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
// v1.16.0 — report the settled data query to the dashboard's shared
|
||||
// reveal gate. `isLoading` only covers the INITIAL fetch (a later
|
||||
// range-tab change creates a new cache entry but the gate has long
|
||||
// latched by then), so this fires exactly when the first paintable
|
||||
// state — chart, empty-window card, or error fallback — is available.
|
||||
useEffect(() => {
|
||||
if (!isLoading) onDataReady?.();
|
||||
}, [isLoading, onDataReady]);
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
if (!data?.length) return data;
|
||||
|
||||
@@ -1521,9 +1541,13 @@ export function HealthChart({
|
||||
) : null}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex h-48 items-center justify-center">
|
||||
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
// v1.16.0 — height-matched skeleton band instead of the former
|
||||
// `h-48` spinner box. The loading state now occupies the exact
|
||||
// box the painted chart will (same `chartHeightClass`), so the
|
||||
// card height never jumps when the data lands — the dashboard's
|
||||
// shared-reveal overlay and every insights mount stay
|
||||
// layout-shift-free.
|
||||
<Skeleton className={`w-full ${chartHeightClass}`} />
|
||||
) : !chartData?.length ? (
|
||||
// v1.4.43 W11-M6 — empty-window state.
|
||||
//
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
* the wrapper itself stays intact.
|
||||
*/
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
@@ -45,9 +45,10 @@ import {
|
||||
Tooltip,
|
||||
ReferenceLine,
|
||||
} from "recharts";
|
||||
import { ArrowDown, ArrowRight, ArrowUp, Loader2, Pill } from "lucide-react";
|
||||
import { ArrowDown, ArrowRight, ArrowUp, Pill } from "lucide-react";
|
||||
import { ComplianceInfoTip } from "@/components/medications/card-parts/compliance-info-tip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { formatDateShort } from "@/lib/format";
|
||||
@@ -188,12 +189,20 @@ interface MedicationComplianceChartProps {
|
||||
* byte-identical.
|
||||
*/
|
||||
userTimezone?: string;
|
||||
/**
|
||||
* v1.16.0 — fires once the compliance query has settled (initial
|
||||
* load finished). The dashboard's shared reveal gate listens here so
|
||||
* every chart cell swaps from skeleton to content in one frame.
|
||||
* Optional and repeat-safe.
|
||||
*/
|
||||
onDataReady?: () => void;
|
||||
}
|
||||
|
||||
export function MedicationComplianceChart({
|
||||
title,
|
||||
userTimezone = "Europe/Berlin",
|
||||
compareBaseline,
|
||||
onDataReady,
|
||||
}: MedicationComplianceChartProps) {
|
||||
// v1.4.27 R4 RC3 — the prop is accepted so the dashboard can pass
|
||||
// `compareBaseline={compareBaseline}` uniformly across every chart.
|
||||
@@ -245,6 +254,12 @@ export function MedicationComplianceChart({
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
// v1.16.0 — report the settled query to the dashboard's shared
|
||||
// reveal gate (see `onDataReady` prop doc).
|
||||
useEffect(() => {
|
||||
if (!isLoading) onDataReady?.();
|
||||
}, [isLoading, onDataReady]);
|
||||
|
||||
const chartData = useMemo(
|
||||
() =>
|
||||
data
|
||||
@@ -400,9 +415,10 @@ export function MedicationComplianceChart({
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex h-48 items-center justify-center">
|
||||
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
// v1.16.0 — height-matched skeleton band instead of the former
|
||||
// `h-48` spinner box; matches the painted chart body below so
|
||||
// the card never jumps when the data lands.
|
||||
<Skeleton className="h-[var(--chart-height,240px)] w-full md:h-[var(--chart-height-md,280px)]" />
|
||||
) : !hasData ? (
|
||||
<div className="text-muted-foreground flex h-48 items-center justify-center text-sm">
|
||||
{t("charts.noData")}
|
||||
|
||||
@@ -14,9 +14,9 @@ import {
|
||||
ReferenceArea,
|
||||
ReferenceLine,
|
||||
} from "recharts";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useState, useMemo } from "react";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { makeFormatters } from "@/lib/format-locale";
|
||||
@@ -93,6 +93,14 @@ interface MoodChartProps {
|
||||
* "Europe/Berlin" so older mount sites stay byte-identical.
|
||||
*/
|
||||
userTimezone?: string;
|
||||
/**
|
||||
* v1.16.0 — fires once the mood analytics query has settled (initial
|
||||
* load finished). The dashboard's shared reveal gate listens here so
|
||||
* every chart cell swaps from skeleton to content in one frame —
|
||||
* pre-fix the cheap `/api/mood/analytics` read made this chart paint
|
||||
* visibly before its measurement siblings. Optional and repeat-safe.
|
||||
*/
|
||||
onDataReady?: () => void;
|
||||
}
|
||||
|
||||
// --- Constants ---
|
||||
@@ -294,6 +302,7 @@ export function MoodChart({
|
||||
compareBaseline = "none",
|
||||
chartKey,
|
||||
userTimezone = "Europe/Berlin",
|
||||
onDataReady,
|
||||
}: MoodChartProps) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { t, locale } = useTranslations();
|
||||
@@ -349,6 +358,14 @@ export function MoodChart({
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
// v1.16.0 — report the settled query to the dashboard's shared reveal
|
||||
// gate (see `onDataReady` prop doc). Must run BEFORE the
|
||||
// empty-entries early return below so a no-data mount still unblocks
|
||||
// the row.
|
||||
useEffect(() => {
|
||||
if (!isLoading) onDataReady?.();
|
||||
}, [isLoading, onDataReady]);
|
||||
|
||||
// v1.4.43 W2-CHART-GATE — raw mood-entry count across every day in
|
||||
// the window. `entries[].samples` carries the per-day raw count
|
||||
// straight from the analytics endpoint; summing it produces the
|
||||
@@ -576,9 +593,17 @@ export function MoodChart({
|
||||
// the padding values — makes the three trend cards share one title
|
||||
// baseline and one height. Full (non-mini) mode keeps the Card path.
|
||||
const chartBody = isLoading ? (
|
||||
<div className="flex h-48 items-center justify-center">
|
||||
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
// v1.16.0 — height-matched skeleton band instead of the former
|
||||
// `h-48` spinner box: the loading state occupies the same box the
|
||||
// painted chart will (mini 140 px / full 200-220 px), so the card
|
||||
// never jumps when the data lands.
|
||||
<Skeleton
|
||||
className={
|
||||
mini
|
||||
? "h-[var(--chart-height,140px)] w-full"
|
||||
: "h-[var(--chart-height,200px)] w-full md:h-[var(--chart-height-md,220px)]"
|
||||
}
|
||||
/>
|
||||
) : !chartData?.length ? (
|
||||
<div className="text-muted-foreground flex h-48 items-center justify-center text-sm">
|
||||
{t("charts.noData")}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* v1.16.0 — structured loading silhouette for one dashboard trend tile.
|
||||
*
|
||||
* Mirrors the real `<TrendCard>` chrome row-for-row (heading row with
|
||||
* label + icon slot, headline value row, the `min-h-[18px]` callout
|
||||
* reservation, the bottom 7d / 30d sub-row) so a cold first paint reads
|
||||
* as "content loading" instead of a bare dark card, and the swap to the
|
||||
* real tile causes zero layout shift — every reserved row sits at the
|
||||
* same y-coordinate the loaded tile paints at.
|
||||
*
|
||||
* Pre-fix the tile-strip skeleton and the per-tile `<Suspense>` fallback
|
||||
* both rendered an EMPTY pulsing card (`min-h-[8rem]` + border, no inner
|
||||
* structure); the first-load impression was a grid of blank rectangles.
|
||||
*
|
||||
* Always `aria-hidden` — the strip-level container announces loading via
|
||||
* the chart skeleton's `role="status"` siblings; a per-tile announcement
|
||||
* would spam screen readers with one entry per silhouette. Reduced
|
||||
* motion is honoured by the `<Skeleton>` primitive
|
||||
* (`motion-reduce:animate-none` on every pulsing block).
|
||||
*/
|
||||
export function TrendCardSkeleton({ className }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-slot="trend-card-skeleton"
|
||||
className={cn(
|
||||
// Same outer chrome as `<TrendCard>` plus the `min-h-[8rem]`
|
||||
// floor the legacy empty silhouette reserved.
|
||||
"bg-card border-border flex h-full min-h-[8rem] w-full min-w-0 flex-col overflow-hidden rounded-xl border p-4 md:p-6",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Heading row — label + icon (TrendCard pins this row at h-5). */}
|
||||
<div className="flex h-5 items-center justify-between gap-2">
|
||||
<Skeleton className="h-3 w-16" />
|
||||
<Skeleton className="h-4 w-4 shrink-0" />
|
||||
</div>
|
||||
{/* Headline value row — `text-3xl leading-none` ≈ 30 px, plus the
|
||||
unit slot, baseline-aligned exactly like the loaded tile. */}
|
||||
<div className="mt-2 flex items-baseline gap-x-1.5">
|
||||
<Skeleton className="h-[30px] w-20" />
|
||||
<Skeleton className="h-3.5 w-8" />
|
||||
</div>
|
||||
{/* Callout slot reservation (comparison delta / stale hint). */}
|
||||
<div className="mt-1 min-h-[18px]" />
|
||||
{/* 7d / 30d sub-row — the sparkline-equivalent bottom block. */}
|
||||
<div className="mt-auto flex items-baseline gap-x-3 pt-1">
|
||||
<Skeleton className="h-3 w-14" />
|
||||
<Skeleton className="h-3 w-14" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* v1.16.0 — shared reveal gate for the dashboard chart row.
|
||||
*
|
||||
* Pre-fix every chart cell popped in independently: the charts mount
|
||||
* together (once the snapshot resolves and the visibility gates flip),
|
||||
* but each one owns its own data query — `/api/mood/analytics` is a
|
||||
* single cheap read while the measurement charts fan out over
|
||||
* `/api/measurements?…`, so the mood chart reliably painted first and
|
||||
* the rest trickled in one after another. The maintainer flagged the
|
||||
* staggered pop-in as unprofessional.
|
||||
*
|
||||
* The gate keeps every chart MOUNTED from the start (so all data
|
||||
* queries fire in parallel) but visually holds each cell on its
|
||||
* layout-stable `<ChartSkeleton>` until either
|
||||
*
|
||||
* 1. every gated chart has reported its data query settled
|
||||
* (`onDataReady` — the Promise.all moment), or
|
||||
* 2. the reveal timeout elapses (`CHART_REVEAL_TIMEOUT_MS`, 2 s) so a
|
||||
* single slow widget cannot hold the whole row hostage — the late
|
||||
* chart simply finishes on its own skeleton-free cell afterwards.
|
||||
*
|
||||
* Both signals are monotonic: the ready-set only grows and the timeout
|
||||
* only fires once, so after the first 2 s the reveal can never regress
|
||||
* even when a later mutation makes an additional chart visible.
|
||||
*/
|
||||
export const CHART_REVEAL_TIMEOUT_MS = 2_000;
|
||||
|
||||
/**
|
||||
* Pure resolver behind `useDashboardChartReveal` — exported so the gate
|
||||
* logic has direct unit coverage without mounting the page (project
|
||||
* convention: SSR-only tests, no DOM test runner).
|
||||
*/
|
||||
export function resolveChartRevealState(input: {
|
||||
expectedIds: readonly string[];
|
||||
readyIds: ReadonlySet<string>;
|
||||
timedOut: boolean;
|
||||
}): boolean {
|
||||
// Not armed yet — the chart gates only flip once the primary
|
||||
// dashboard query resolves, so an empty id list means there is
|
||||
// nothing to reveal (and nothing rendered that could need it).
|
||||
if (input.expectedIds.length === 0) return false;
|
||||
if (input.timedOut) return true;
|
||||
return input.expectedIds.every((id) => input.readyIds.has(id));
|
||||
}
|
||||
|
||||
export function useDashboardChartReveal(
|
||||
expectedIds: readonly string[],
|
||||
timeoutMs: number = CHART_REVEAL_TIMEOUT_MS,
|
||||
): { revealed: boolean; markReady: (id: string) => void } {
|
||||
const [readyIds, setReadyIds] = useState<ReadonlySet<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const [timedOut, setTimedOut] = useState(false);
|
||||
|
||||
// Arm the timeout fallback the moment the first chart id shows up
|
||||
// (i.e. when the primary query resolved and the chart row mounts).
|
||||
// The timer deliberately runs to completion even when every chart
|
||||
// reports ready earlier — a fired `timedOut` latches the reveal open
|
||||
// so a chart that becomes visible later (e.g. the first mood entry
|
||||
// logged via quick-add) cannot flip the whole row back to skeletons.
|
||||
const armed = expectedIds.length > 0;
|
||||
useEffect(() => {
|
||||
if (!armed) return undefined;
|
||||
const handle = setTimeout(() => setTimedOut(true), timeoutMs);
|
||||
return () => clearTimeout(handle);
|
||||
}, [armed, timeoutMs]);
|
||||
|
||||
// Idempotent — re-marking a ready id returns the same Set reference
|
||||
// so the repeated `onDataReady` calls from re-rendered charts never
|
||||
// cause a render loop.
|
||||
const markReady = useCallback((id: string) => {
|
||||
setReadyIds((prev) => (prev.has(id) ? prev : new Set(prev).add(id)));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
revealed: resolveChartRevealState({ expectedIds, readyIds, timedOut }),
|
||||
markReady,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One reveal-gated chart cell. The chart stays mounted underneath
|
||||
* (`visibility: hidden` keeps its layout box, so Recharts'
|
||||
* `ResponsiveContainer` measures real dimensions and the cell height
|
||||
* never jumps at reveal time) while a `<ChartSkeleton>` overlays the
|
||||
* exact same box. On reveal every cell swaps in the same frame with a
|
||||
* short fade-in; `motion-safe:` keeps the swap instant for
|
||||
* reduced-motion users.
|
||||
*/
|
||||
export function DashboardChartCell({
|
||||
revealed,
|
||||
children,
|
||||
}: {
|
||||
revealed: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
data-slot="dashboard-chart-cell"
|
||||
data-revealed={revealed ? "true" : "false"}
|
||||
>
|
||||
<div
|
||||
aria-hidden={revealed ? undefined : true}
|
||||
className={cn(
|
||||
// `space-y-2` preserves the chart-row rhythm the dashboard's
|
||||
// outer cell applied when chart + TrendHint were direct
|
||||
// siblings (pre-gate markup).
|
||||
"space-y-2",
|
||||
revealed
|
||||
? "motion-safe:animate-in motion-safe:fade-in motion-safe:duration-300"
|
||||
: "invisible",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{!revealed && (
|
||||
<ChartSkeleton className="absolute inset-0 overflow-hidden" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { CoachSettingsSheet } from "./coach-settings-sheet";
|
||||
import { HistoryRail } from "./history-rail";
|
||||
import { MessageThread } from "./message-thread";
|
||||
import { MobileRailTray } from "./mobile-rail-tray";
|
||||
import { SelfContextChips } from "./self-context-chips";
|
||||
import { SourcesRail } from "./sources-rail";
|
||||
import { useResettableValue } from "./use-resettable-value";
|
||||
import { useCoachConversation, useSendCoachMessage } from "./use-coach";
|
||||
@@ -220,15 +221,25 @@ export function CoachConversation({
|
||||
</p>
|
||||
}
|
||||
composer={
|
||||
<CoachInput
|
||||
value={inputValue}
|
||||
onChange={setInputValue}
|
||||
onSubmit={() => handleSubmit(inputValue)}
|
||||
onCancel={send.cancel}
|
||||
disabled={send.isStreaming}
|
||||
isStreaming={send.isStreaming}
|
||||
autoFocusOnOpen={autoFocusComposer}
|
||||
/>
|
||||
<div>
|
||||
{/* v1.16.0 — pending self-context questions as tappable
|
||||
chips. Tapping inserts the question into the composer
|
||||
(the user answers it in their own words) and dismisses
|
||||
the chip. Renders nothing when no questions pend. */}
|
||||
<SelfContextChips
|
||||
disabled={send.isStreaming}
|
||||
onPick={(question) => setInputValue(`${question}\n`)}
|
||||
/>
|
||||
<CoachInput
|
||||
value={inputValue}
|
||||
onChange={setInputValue}
|
||||
onSubmit={() => handleSubmit(inputValue)}
|
||||
onCancel={send.cancel}
|
||||
disabled={send.isStreaming}
|
||||
isStreaming={send.isStreaming}
|
||||
autoFocusOnOpen={autoFocusComposer}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
onOpenHistoryTray={() => setHistoryTrayOpen(true)}
|
||||
onOpenSourcesTray={() => setSourcesTrayOpen(true)}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.16.0 — pending self-context questions as composer chips.
|
||||
*
|
||||
* After the user saves Settings → AI → "About me", the server derives
|
||||
* up to 3 clarifying questions (`/api/coach/about-me/questions`). They
|
||||
* surface here, directly above the Coach composer, as quiet tappable
|
||||
* chips: tapping one inserts the question into the composer (so the
|
||||
* user answers it in their own words) and dismisses it; the ✕
|
||||
* dismisses without inserting. No questions → nothing renders, zero
|
||||
* vertical cost.
|
||||
*
|
||||
* Each chip is a `role="group"` of two sibling buttons (insert +
|
||||
* dismiss) — buttons cannot nest, and both actions deserve their own
|
||||
* focus stop.
|
||||
*/
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { MessageCircleQuestion, X } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface SelfContextChipsProps {
|
||||
/** Insert the tapped question into the composer. */
|
||||
onPick: (question: string) => void;
|
||||
/** Hidden while a reply streams so the input value cannot be yanked. */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function SelfContextChips({
|
||||
onPick,
|
||||
disabled = false,
|
||||
}: SelfContextChipsProps) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: queryKeys.coachAboutMeQuestions(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/coach/about-me/questions");
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return (await res.json()).data as { questions: string[] };
|
||||
},
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const dismiss = useMutation({
|
||||
mutationKey: queryKeys.coachAboutMeQuestions(),
|
||||
mutationFn: async (question: string) => {
|
||||
const res = await fetch("/api/coach/about-me/questions", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ question }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return (await res.json()).data as { questions: string[] };
|
||||
},
|
||||
onSuccess: (next) => {
|
||||
queryClient.setQueryData(queryKeys.coachAboutMeQuestions(), next);
|
||||
},
|
||||
});
|
||||
|
||||
const questions = data?.questions ?? [];
|
||||
if (questions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div data-slot="coach-self-context-chips" className="mb-2">
|
||||
<p className="text-muted-foreground mb-1.5 flex items-center gap-1.5 text-[11px]">
|
||||
<MessageCircleQuestion
|
||||
className="text-dracula-purple size-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t("insights.coach.selfContextChipsLabel")}
|
||||
</p>
|
||||
<ul className="flex flex-wrap gap-1.5">
|
||||
{questions.map((question) => (
|
||||
<li
|
||||
key={question}
|
||||
role="group"
|
||||
aria-label={question}
|
||||
className={cn(
|
||||
"border-dracula-purple/30 bg-dracula-purple/10 inline-flex max-w-full items-stretch overflow-hidden rounded-full border",
|
||||
"transition-colors",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
data-slot="coach-self-context-chip"
|
||||
onClick={() => {
|
||||
onPick(question);
|
||||
dismiss.mutate(question);
|
||||
}}
|
||||
title={question}
|
||||
className={cn(
|
||||
"text-foreground min-h-9 min-w-0 truncate py-1.5 pr-1 pl-3 text-left text-xs",
|
||||
"hover:bg-dracula-purple/15 focus-visible:ring-dracula-purple/40 focus-visible:ring-2 focus-visible:outline-none",
|
||||
"disabled:opacity-60",
|
||||
)}
|
||||
>
|
||||
{question}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-label={t("insights.coach.selfContextChipDismiss")}
|
||||
data-slot="coach-self-context-chip-dismiss"
|
||||
onClick={() => dismiss.mutate(question)}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground flex items-center px-2",
|
||||
"hover:bg-dracula-purple/15 focus-visible:ring-dracula-purple/40 focus-visible:ring-2 focus-visible:outline-none",
|
||||
"disabled:opacity-60",
|
||||
)}
|
||||
>
|
||||
<X className="size-3" aria-hidden="true" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user