fix(dashboard): key streak and compliance days in the person's own zone

`measured_at` and `scheduled_for` are naive UTC timestamps. The dashboard
streak and the medication-compliance backfill keyed them with a single
`AT TIME ZONE tz`, which reads the value as local time and shifts it the
wrong way: in Berlin a reading at 23:30 UTC counted for the day before
instead of the day after, so a streak could break or stretch near
midnight and the backfill recomputed the wrong day.

The streak's measurement days now come from `readActivityDayKeys`: one
existence probe per local day instead of a DISTINCT over a year of
readings. The DAY rollups cannot answer this because their buckets are
UTC days. The two compliance-rollup readers had no caller left and are
removed with the coverage probe that carried the same shift. A guard
fails on any single shift of a naive column into a bound zone.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 03:35:19 +02:00
1 parent 9c51dd0d30
commit 6026a28fe4
7 files changed
+258 -292

No files matched your search

@@ -97,10 +97,9 @@ describe("dashboard summary reads — the route keeps using them", () => {
expect(route).toMatch(
/readSparkBuckets\(\s*prisma,\s*userId,\s*measurementTypes,/,
);
// No second copy of the rollup read can drift out of the guarded
// module. (`FROM measurements` still appears in the route's own
// streak-day aggregate, which is a bounded 365-row read and not part
// of this finding.)
expect(route).toMatch(/readActivityDayKeys\(\s*prisma,\s*userId,/);
// No second copy of a read can drift out of the guarded module.
expect(route.includes("FROM measurement_rollups")).toBe(false);
expect(route.includes("FROM measurements")).toBe(false);
});
});
@@ -0,0 +1,67 @@
/**
* Structural guard: a naive UTC timestamp is read as UTC before it is
* shifted into a person's zone.
*
* `measured_at`, `scheduled_for`, `taken_at` and the other `*_at` instants
* are `timestamp` columns holding UTC wall-clock time. On that type,
* `col AT TIME ZONE tz` means "this is local time in tz" and returns the
* matching instant, so the value moves by the offset in the wrong direction:
* in Berlin a 23:30 UTC reading lands on the day before instead of the day
* after. The right form is `(col AT TIME ZONE 'UTC') AT TIME ZONE tz`. The
* dashboard streak and the medication-compliance backfill both keyed days
* with the single form, while their comments called it identical to
* `userDayKey`.
*
* The matcher flags a quoted `*_at` or `scheduled_for` column shifted
* straight into a bound zone (`${...}` or `$N`). A `timestamptz` column
* (`bucket_start`) is out of its reach by name. Comments are stripped first;
* the planted cases prove it fires. It is a tripwire, not a proof.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { walkSourceFiles } from "./helpers/source-files";
const SRC = join(process.cwd(), "src");
const SINGLE_SHIFT =
/"(?:[a-z_]+_at|scheduled_for)"\s*\)?\s*AT\s+TIME\s+ZONE\s+(?:\$\{|\$\d)/i;
function stripComments(text: string): string {
return text
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/^\s*\/\/.*$/gm, "")
.replace(/^\s*--.*$/gm, "");
}
describe("naive timestamp zone shifts", () => {
it("the matcher fires on the single shift and spares the double one", () => {
expect(
SINGLE_SHIFT.test(`to_char(m."measured_at" AT TIME ZONE \${tz}, 'D')`),
).toBe(true);
expect(SINGLE_SHIFT.test(`("scheduled_for" AT TIME ZONE $3)`)).toBe(true);
expect(
SINGLE_SHIFT.test(
`("scheduled_for" AT TIME ZONE 'UTC') AT TIME ZONE \${tz}`,
),
).toBe(false);
expect(SINGLE_SHIFT.test(`r."bucket_start" AT TIME ZONE \${tz}`)).toBe(
false,
);
});
it("no production read shifts a naive column once", () => {
const offenders = walkSourceFiles(SRC, { floor: 3000 })
.filter(
(file) =>
!file.startsWith("generated/") &&
!file.includes("__tests__/") &&
!/\.test\.tsx?$/.test(file),
)
.filter((file) =>
SINGLE_SHIFT.test(stripComments(readFileSync(join(SRC, file), "utf8"))),
);
expect(offenders).toEqual([]);
});
});
+6 -37
View File
@@ -78,6 +78,7 @@ import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
import { buildMedsTodayBlock } from "@/lib/dashboard/meds-today";
import { computeStreak } from "@/lib/dashboard/streak";
import {
readActivityDayKeys,
readLatestEver,
readSparkBuckets,
} from "@/lib/dashboard/summary-reads";
@@ -329,15 +330,6 @@ function trendOf(values: number[]): MetricCard["trend"] {
return delta > 0 ? "up" : "down";
}
/** v1.4.38 W-F — distinct activity day-keys from the streak window.
* The route only needs YYYY-MM-DD strings, so the aggregate runs
* `date_trunc('day', m.measured_at AT TIME ZONE $userTz)` and returns
* the day-keys directly. At most 365 rows; vs. the legacy `findMany`
* which could return tens of thousands. */
interface ActivityDayRow {
day_key: string;
}
export const GET = apiHandler(async () => {
// v1.37.0 — MANAGE-level read: computed over the whole record, with no
// provider anywhere on the path.
@@ -532,27 +524,8 @@ async function buildDashboardSummary(
select: { takenAt: true, scheduledFor: true },
}),
),
// v1.4.38 W-F — replaces the legacy 365-day `measurement.findMany`
// that pulled every raw row in the window only to collapse them to
// day-keys in JS. The SQL `date_trunc` returns at most 365 rows
// even on accounts with millions of step samples. The `userTz`
// conversion is folded server-side so the day boundary matches
// `userDayKey`'s output (it formats the timestamp in the user's
// tz before slicing YYYY-MM-DD).
time(
"streakDays",
() =>
prisma.$queryRaw<ActivityDayRow[]>`
SELECT DISTINCT
to_char(
(m."measured_at" AT TIME ZONE ${userTz}),
'YYYY-MM-DD'
) AS day_key
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."measured_at" >= ${streakWindowStart}
AND m."deleted_at" IS NULL
`,
time("streakDays", () =>
readActivityDayKeys(prisma, userId, userTz, streakWindowStart, now),
),
// v1.11.4 — raw per-stage SLEEP_DURATION rows for the night-total
// tile. `SLEEP_DURATION` is stored one row per STAGE per night
@@ -635,13 +608,9 @@ async function buildDashboardSummary(
}
// v1.4.38 W-F — activity-day set assembled from the bounded streak
// queries. The measurement side already arrives as YYYY-MM-DD keys
// from the SQL `date_trunc + AT TIME ZONE` aggregate, byte-identical
// to what `userDayKey(measuredAt, userTz)` would produce in JS.
const activityDays = new Set<string>();
for (const row of measurementStreakDays) {
if (row.day_key) activityDays.add(row.day_key);
}
// queries. The measurement side arrives as local YYYY-MM-DD keys, the
// same keys `userDayKey(measuredAt, userTz)` produces for the intakes.
const activityDays = new Set<string>(measurementStreakDays);
for (const e of streakActivity) {
activityDays.add(userDayKey(e.takenAt ?? e.scheduledFor, userTz));
}
+43
View File
@@ -165,3 +165,46 @@ export function readSparkBuckets(
ORDER BY type, bucket_start ASC
`;
}
/**
* The local days in `[since, now]` that hold at least one live reading, as
* `YYYY-MM-DD` keys in `timeZone`: the measurement half of the streak.
*
* One existence probe per local day against `(user_id, measured_at)`, each
* stopping at its first row, instead of a `DISTINCT` over a year of readings
* (tens of thousands of rows on an account with a watch). The DAY rollups
* cannot answer this: their buckets are UTC days, and the streak counts the
* person's own days.
*
* `measured_at` is a naive UTC timestamp, so a local day's bounds are its
* local midnights read in `timeZone` and taken back to UTC. The read this
* replaced shifted the column with a single `AT TIME ZONE`, which treats the
* UTC value as local time and moves it the wrong way: in Berlin a reading at
* 23:30 UTC landed on the day before instead of the day after.
*/
export async function readActivityDayKeys(
db: PrismaClient,
userId: string,
timeZone: string,
since: Date,
now: Date,
): Promise<string[]> {
const rows = await db.$queryRaw<Array<{ day_key: string }>>`
SELECT to_char(d, 'YYYY-MM-DD') AS day_key
FROM generate_series(
(${since}::timestamptz AT TIME ZONE ${timeZone})::date,
(${now}::timestamptz AT TIME ZONE ${timeZone})::date,
INTERVAL '1 day'
) AS d
WHERE EXISTS (
SELECT 1
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."deleted_at" IS NULL
AND m."measured_at" >= (d::timestamp AT TIME ZONE ${timeZone}) AT TIME ZONE 'UTC'
AND m."measured_at" < ((d + INTERVAL '1 day')::timestamp AT TIME ZONE ${timeZone}) AT TIME ZONE 'UTC'
)
ORDER BY d
`;
return rows.map((row) => row.day_key);
}
@@ -50,8 +50,6 @@ import {
dayKeyForScheduledFor,
recomputeMedicationComplianceForDay,
recomputeMedicationComplianceForEvent,
readMedicationCompliance,
hasMedicationComplianceCoverage,
recomputeUserMedicationCompliance,
enqueueBootTimeMedicationComplianceBackfill,
enqueueUserMedicationComplianceBackfill,
@@ -317,107 +315,6 @@ describe("recomputeMedicationComplianceForEvent", () => {
});
});
describe("readMedicationCompliance", () => {
it("returns a trailing-window zero-filled bucket array", async () => {
rollupFindMany.mockResolvedValue([
{ day: "2026-05-18", scheduled: 2, taken: 1, skipped: 1 },
]);
const now = new Date("2026-05-18T12:00:00.000Z");
const buckets = await readMedicationCompliance(
"user-1",
3,
"Europe/Berlin",
now,
);
expect(buckets).toHaveLength(3);
expect(buckets[buckets.length - 1]).toEqual({
date: "2026-05-18",
scheduled: 2,
taken: 1,
});
expect(buckets[0].scheduled).toBe(0);
expect(buckets[0].taken).toBe(0);
expect(buckets.map((b) => b.date)).toEqual([
"2026-05-16",
"2026-05-17",
"2026-05-18",
]);
});
it("folds per-medication rows into one per-day total", async () => {
rollupFindMany.mockResolvedValue([
{ day: "2026-05-18", scheduled: 1, taken: 1, skipped: 0 },
{ day: "2026-05-18", scheduled: 2, taken: 0, skipped: 1 },
]);
const now = new Date("2026-05-18T12:00:00.000Z");
const buckets = await readMedicationCompliance(
"user-1",
1,
"Europe/Berlin",
now,
);
expect(buckets[0].scheduled).toBe(3);
expect(buckets[0].taken).toBe(1);
});
});
describe("hasMedicationComplianceCoverage", () => {
it("returns true when rolled-day count meets the event-day count in window", async () => {
queryRaw.mockResolvedValue([
{ rolled_days: BigInt(7), event_days: BigInt(7) },
]);
await expect(
hasMedicationComplianceCoverage(
"user-1",
7,
"Europe/Berlin",
new Date("2026-05-18T12:00:00.000Z"),
),
).resolves.toBe(true);
});
it("returns true when the user has zero events in window (trivially covered)", async () => {
// Zero events → the read path returns a trailing-window zero-fill
// from an empty rollup table; covered semantically.
queryRaw.mockResolvedValue([
{ rolled_days: BigInt(0), event_days: BigInt(0) },
]);
await expect(
hasMedicationComplianceCoverage("user-1", 7, "Europe/Berlin"),
).resolves.toBe(true);
});
it("returns false on partial coverage (boot backfill mid-fold)", async () => {
// QA F-H-01 (v1.4.39): the legacy "any row exists" probe would
// flip true here and serve zero-filled buckets for days N..days-1
// that the backfill hasn't reached yet. With the tightened probe
// partial coverage forces the route into the legacy aggregator
// until the fold completes.
queryRaw.mockResolvedValue([
{ rolled_days: BigInt(2), event_days: BigInt(7) },
]);
await expect(
hasMedicationComplianceCoverage(
"user-1",
7,
"Europe/Berlin",
new Date("2026-05-18T12:00:00.000Z"),
),
).resolves.toBe(false);
});
it("returns false on zero rollups when events exist (legacy account cold start)", async () => {
queryRaw.mockResolvedValue([
{ rolled_days: BigInt(0), event_days: BigInt(7) },
]);
await expect(
hasMedicationComplianceCoverage("user-1", 7, "Europe/Berlin"),
).resolves.toBe(false);
});
});
describe("recomputeUserMedicationCompliance", () => {
it("upserts one rollup per (medication, day) pair returned by discovery", async () => {
queryRaw.mockResolvedValue([
+15 -148
View File
@@ -9,10 +9,11 @@
* Write hooks fire from every `MedicationIntakeEvent` mutation site
* (create / update / delete / bulk import) plus the reminder-worker's
* mint path that creates `takenAt:null` rows on RED-phase reminders.
* Reads collapse the trailing N-day window into a single bounded
* `findMany` against `medication_compliance_rollups`; the route-level
* coverage probe falls back to live + fires a boot backfill when a
* user has intake events but zero rollup rows.
* The dashboard tile it was built for reads the schedule-anchored
* compliance (`src/lib/analytics/schedule-anchored-compliance.ts`) since
* v1.26, and the per-day recompute below is now what keeps a day's
* supplement nutrients in step with its doses; the table itself has no
* reader left.
*
* v1.15.18 DESIGN DECISION — this tier stays a RAW per-day COVERAGE count,
* NOT the band-attributed compliance %. It aggregates, across ALL of a
@@ -77,14 +78,6 @@ export interface MedicationComplianceBackfillPayload {
enqueuedAt: string;
}
/** Reader-result row shape — matches `buildComplianceBuckets` output. */
export interface ComplianceBucket {
/** YYYY-MM-DD in the user's tz. */
date: string;
scheduled: number;
taken: number;
}
/**
* Resolve a safe tz string — fall back to the server default when the
* supplied value is missing or not an IANA zone the host recognises.
@@ -254,138 +247,6 @@ export async function recomputeMedicationComplianceForEvent(input: {
}
}
/**
* Read trailing `days` of compliance buckets for the user, oldest day
* first. Returns the same `{date, scheduled, taken}` shape the legacy
* `buildComplianceBuckets` helper produced so the route swap is
* byte-identical.
*
* Days without a rollup row land as zero-filled buckets. The caller's
* cache wrapper carries the user-tz in its key so a tz change forces
* a fresh read.
*/
export async function readMedicationCompliance(
userId: string,
days: number,
tz: string | null | undefined,
now: Date = new Date(),
): Promise<ComplianceBucket[]> {
const safeTz = safeTimezone(tz);
// Build the expected day-key window oldest → newest so the response
// is sorted and zero-filled regardless of which days hold rollups.
const todayKey = userDayKey(now, safeTz);
const expectedKeys: string[] = [];
for (let i = days - 1; i >= 0; i--) {
const probe = new Date(now.getTime() - i * 86_400_000);
expectedKeys.push(userDayKey(probe, safeTz));
}
// Guard against a DST day-of-the-year landing the loop on the same
// dayKey twice — dedup by Map insertion order.
const dedup = new Set<string>();
const orderedKeys: string[] = [];
for (const key of expectedKeys) {
if (!dedup.has(key)) {
dedup.add(key);
orderedKeys.push(key);
}
}
// Always include today even if the trailing window dropped it (e.g.
// days===1 on a DST boundary). Cheap defensive insert.
if (!dedup.has(todayKey)) {
orderedKeys.push(todayKey);
}
const oldestKey = orderedKeys[0];
const newestKey = orderedKeys[orderedKeys.length - 1];
const rows = await prisma.medicationComplianceRollup.findMany({
where: {
userId,
day: { gte: oldestKey, lte: newestKey },
},
select: { day: true, scheduled: true, taken: true, skipped: true },
});
// Fold per-medication rows into one per-day total. The legacy route
// shape was aggregated across all the user's medications; preserving
// that surface keeps the front-end byte-stable.
const totals = new Map<string, { scheduled: number; taken: number }>();
for (const key of orderedKeys) {
totals.set(key, { scheduled: 0, taken: 0 });
}
for (const row of rows) {
const bucket = totals.get(row.day);
if (!bucket) continue;
bucket.scheduled += row.scheduled;
bucket.taken += row.taken;
}
return orderedKeys.map((date) => {
const bucket = totals.get(date) ?? { scheduled: 0, taken: 0 };
return { date, scheduled: bucket.scheduled, taken: bucket.taken };
});
}
/**
* Returns true when the rollup tier covers every day inside the
* trailing `days` window that has at least one intake event. Coverage
* is full when the count of DISTINCT rolled days equals the count of
* DISTINCT days that hold a `medication_intake_events` row in window —
* partial coverage (boot backfill mid-fold on a multi-medication
* account) returns false so the route falls through to the legacy
* aggregator instead of serving zero-filled tiles for un-rolled days.
*
* v1.4.39 hotfix (QA F-H-01): the previous "any row exists" probe
* could short-circuit to the rollup path while only the first few days
* had been backfilled, exposing zero-filled tiles for the un-rolled
* days until the boot fold completed.
*/
export async function hasMedicationComplianceCoverage(
userId: string,
days: number,
tz: string | null | undefined,
now: Date = new Date(),
): Promise<boolean> {
const safeTz = safeTimezone(tz);
const oldestKey = userDayKey(
new Date(now.getTime() - (days - 1) * 86_400_000),
safeTz,
);
const oldestStart = startOfLocalDayKey(oldestKey, safeTz);
// Single SQL aggregate: count DISTINCT rolled days vs DISTINCT
// event-days in window. The event side is anchored on the same
// user-tz day-key (`to_char(... AT TIME ZONE $tz, 'YYYY-MM-DD')`) so
// the comparison stays apples-to-apples even on DST boundaries.
const result = await prisma.$queryRaw<
Array<{ rolled_days: bigint; event_days: bigint }>
>`
SELECT
(
SELECT COUNT(DISTINCT "day")::bigint
FROM "medication_compliance_rollups"
WHERE "user_id" = ${userId}
AND "day" >= ${oldestKey}
) AS rolled_days,
(
SELECT COUNT(DISTINCT to_char("scheduled_for" AT TIME ZONE ${safeTz}, 'YYYY-MM-DD'))::bigint
FROM "medication_intake_events"
WHERE "user_id" = ${userId}
AND "deleted_at" IS NULL
AND "scheduled_for" >= ${oldestStart}
) AS event_days
`;
if (result.length === 0) return true;
const { rolled_days, event_days } = result[0];
const rolled = Number(rolled_days);
const events = Number(event_days);
// Zero events in window → trivially covered (the read path produces
// a zero-filled trailing window from the empty rollup table).
if (events === 0) return true;
return rolled >= events;
}
/**
* Full-fold helper. Walks every `(medicationId, day)` pair the user
* has logged across the trailing `days` window and upserts each
@@ -395,7 +256,7 @@ export async function hasMedicationComplianceCoverage(
*
* Bounded SQL: one `DISTINCT (medicationId, dayKey)` $queryRaw scan
* over the trailing window, then one upsert per row. The dayKey is
* computed in Postgres via `to_char(... AT TIME ZONE $tz)` so we
* computed in Postgres in the user's zone so we
* don't have to ship millions of rows back to Node.
*/
export async function recomputeUserMedicationCompliance(
@@ -414,14 +275,20 @@ export async function recomputeUserMedicationCompliance(
const oldestStart = startOfLocalDayKey(oldestKey, safeTz);
// Distinct (medication_id, dayKey) pairs the user has any intake
// event for inside the trailing window. `to_char(... AT TIME ZONE
// $tz)` keeps the day-bucket on the user's wall-clock.
// event for inside the trailing window. `scheduled_for` is a naive UTC
// timestamp: it is read as UTC first and only then shifted into the
// user's zone. A single `AT TIME ZONE $tz` reads it as local time and
// shifts it the wrong way, which keyed every slot within the zone's
// offset of midnight onto the neighbouring day.
const pairs = await prisma.$queryRaw<
Array<{ medication_id: string; day: string }>
>`
SELECT DISTINCT
"medication_id",
to_char("scheduled_for" AT TIME ZONE ${safeTz}, 'YYYY-MM-DD') AS "day"
to_char(
("scheduled_for" AT TIME ZONE 'UTC') AT TIME ZONE ${safeTz},
'YYYY-MM-DD'
) AS "day"
FROM "medication_intake_events"
WHERE "user_id" = ${userId}
AND "deleted_at" IS NULL
+124
View File
@@ -0,0 +1,124 @@
/**
* Local day keys from naive UTC timestamps, against a real Postgres.
*
* `measured_at` and `scheduled_for` hold UTC wall-clock time in `timestamp`
* columns. The dashboard streak and the medication-compliance backfill keyed
* them with a single `AT TIME ZONE tz`, which reads the value as local time
* and shifts it the wrong way. Both now agree with `userDayKey`: a reading at
* 23:30 UTC is the next day in Berlin and still the same day in Los Angeles,
* a reading at 03:00 UTC is the previous day in Los Angeles.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
import { readActivityDayKeys } from "@/lib/dashboard/summary-reads";
import { recomputeUserMedicationCompliance } from "@/lib/rollups/medication-compliance-rollups";
import { userDayKey } from "@/lib/tz/format";
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
const prisma = getPrismaClient();
const USER = "local-day-keys";
const now = new Date();
/** UTC midnight ten days back. */
const D = Date.UTC(
now.getUTCFullYear(),
now.getUTCMonth(),
now.getUTCDate() - 10,
);
const LATE = new Date(D + 23.5 * 3_600_000); // 23:30 UTC on D
const EARLY = new Date(D + 3 * 3_600_000); // 03:00 UTC on D
const key = (ms: number) => new Date(ms).toISOString().slice(0, 10);
beforeEach(async () => {
await truncateAllTables(prisma);
await prisma.user.create({
data: {
id: USER,
username: USER,
email: `${USER}@example.test`,
timezone: "Europe/Berlin",
},
});
});
describe("readActivityDayKeys", () => {
beforeEach(async () => {
await prisma.measurement.createMany({
data: [
{ at: LATE, deleted: false },
{ at: EARLY, deleted: false },
{ at: new Date(D - 3 * 86_400_000), deleted: true },
].map(({ at, deleted }, i) => ({
id: `${USER}-m${i}`,
userId: USER,
type: "WEIGHT" as const,
source: "MANUAL" as const,
value: 80,
unit: "kg",
measuredAt: at,
deletedAt: deleted ? new Date() : null,
})),
});
});
const since = new Date(D - 5 * 86_400_000);
it("keys a late reading onto the next day east of UTC", async () => {
const keys = await readActivityDayKeys(
prisma,
USER,
"Europe/Berlin",
since,
now,
);
expect(keys).toEqual([key(D), key(D + 86_400_000)]);
expect(keys).toEqual(
[EARLY, LATE].map((at) => userDayKey(at, "Europe/Berlin")),
);
});
it("keys an early reading onto the previous day west of UTC", async () => {
const keys = await readActivityDayKeys(
prisma,
USER,
"America/Los_Angeles",
since,
now,
);
expect(keys).toEqual([key(D - 86_400_000), key(D)]);
expect(keys).toEqual(
[EARLY, LATE].map((at) => userDayKey(at, "America/Los_Angeles")),
);
});
});
describe("recomputeUserMedicationCompliance", () => {
it("keys a late dose onto the person's own day", async () => {
const med = await prisma.medication.create({
data: { userId: USER, name: "Ramipril", dose: "5mg", active: true },
});
await prisma.medicationIntakeEvent.create({
data: {
userId: USER,
medicationId: med.id,
scheduledFor: LATE,
takenAt: LATE,
skipped: false,
source: "API",
},
});
await recomputeUserMedicationCompliance(USER, 30, "Europe/Berlin");
const rows = await prisma.medicationComplianceRollup.findMany({
where: { userId: USER },
select: { day: true, scheduled: true, taken: true },
});
expect(rows).toEqual([
{ day: key(D + 86_400_000), scheduled: 1, taken: 1 },
]);
});
});