mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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:
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user