mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
perf(medications): one compliance round trip and a memoised formatter hot path
Intl.DateTimeFormat instances are memoised per locale, timezone and options signature — construction dominated the 365-day ledger build and the hot path drops about fivefold. A batched GET /api/medications/compliance serves every card from one request through the same per-medication cache cells the detail page reads, replacing the per-card fan-out; the list response stays untouched so the due line never waits on a ledger build. The medications and compliance buckets serve stale-while-revalidate, interactive writes evict while background bulk sync marks stale, the per-id route defers ownership and schedule reads until a cache miss, and the card body reserves constant slots for the status and cycle lines so late data stops shifting the layout.
This commit is contained in:
1 parent
23f7c74c04
commit
b346e78cc7
36 files changed
+1453
-500
No files matched your search
+132
-72
@@ -2366,6 +2366,27 @@ paths:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/medications/compliance:
|
||||
get:
|
||||
tags:
|
||||
- Medications
|
||||
summary: Batched adherence read for every medication of the caller
|
||||
description: Returns one compact adherence row per medication the caller owns (active + paused), ordered by `createdAt
|
||||
DESC` — the single round trip the medication cards consume instead of fanning out one
|
||||
`/api/medications/{id}/compliance` request per card. Each row carries the 7-/30-day summaries and the
|
||||
cadence-scaled display block; the per-day grid stays on the per-medication endpoint. Pure computation — no
|
||||
writes. Served through the same per-medication server cache as the per-id read, so the two endpoints warm each
|
||||
other.
|
||||
responses:
|
||||
"200":
|
||||
description: One adherence row per medication.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ListMedicationComplianceResponse"
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/medications/{id}/compliance:
|
||||
get:
|
||||
tags:
|
||||
@@ -8897,11 +8918,13 @@ components:
|
||||
additionalProperties: false
|
||||
description: One expected-vs-actual dose slot for the cadence timeline chart. `status` is one of `taken | skipped |
|
||||
missed | pending | future` and drives the chip colour.
|
||||
GetMedicationComplianceResponse:
|
||||
ListMedicationComplianceResponse:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/MedicationComplianceResponse"
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/MedicationComplianceSummaryEntry"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
@@ -8914,33 +8937,26 @@ components:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
MedicationComplianceResponse:
|
||||
MedicationComplianceSummaryEntry:
|
||||
type: object
|
||||
properties:
|
||||
medicationId:
|
||||
type: string
|
||||
compliance7:
|
||||
$ref: "#/components/schemas/ComplianceResult"
|
||||
compliance30:
|
||||
$ref: "#/components/schemas/ComplianceResult"
|
||||
dailyCompliance:
|
||||
type: object
|
||||
propertyNames:
|
||||
type: string
|
||||
additionalProperties:
|
||||
$ref: "#/components/schemas/DailyComplianceEntry"
|
||||
description: Flat per-day map keyed `YYYY-MM-DD` in the user timezone, one entry per day for up to 90 days back, clamped
|
||||
to the medication's `createdAt` (so a recently-created med has fewer entries). No weekly/monthly collapse —
|
||||
this is the raw daily grid.
|
||||
complianceDisplay:
|
||||
$ref: "#/components/schemas/ComplianceDisplay"
|
||||
required:
|
||||
- medicationId
|
||||
- compliance7
|
||||
- compliance30
|
||||
- dailyCompliance
|
||||
- complianceDisplay
|
||||
additionalProperties: false
|
||||
description: Adherence read for a single medication. `compliance30` is the authoritative 30-day taken-vs-expected
|
||||
summary; `dailyCompliance` is the per-day grid for the history glyph track. The graded raw→week→month→year
|
||||
series used elsewhere for AI prompts does NOT apply here — this response is never downsampled.
|
||||
description: "Compact per-medication adherence row for the batched card read: the 7-/30-day summaries plus the
|
||||
cadence-scaled display block. The per-day `dailyCompliance` grid is NOT on this shape — read the per-medication
|
||||
`/compliance` endpoint for the history glyph track."
|
||||
ComplianceResult:
|
||||
type: object
|
||||
properties:
|
||||
@@ -8984,62 +9000,6 @@ components:
|
||||
description: Rolling-window adherence summary. `compliance30` is the authoritative 'last 30 days, taken vs expected'
|
||||
read — clients should display `rate` and use `totalExpected` as the denominator rather than re-deriving it from
|
||||
the daily map.
|
||||
DailyComplianceEntry:
|
||||
type: object
|
||||
properties:
|
||||
expected:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
description: Engine-computed due-slot count for the day. Equals `expectedCount`; kept for existing consumers.
|
||||
expectedCount:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
description: True due-slot count for the day (additive field clients key off so they don't infer due-ness from
|
||||
`expected`).
|
||||
due:
|
||||
type: boolean
|
||||
description: "`expectedCount > 0`. Paint a per-day glyph as expected/missed ONLY when `due === true`; off-cadence /
|
||||
pre-creation / PRN days are not misses."
|
||||
taken:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
skipped:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
onTime:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
description: Doses taken in the on-time band, including the `early` bucket (early counts as compliant).
|
||||
late:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
veryLate:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
early:
|
||||
description: Doses taken before the on-time band's grace start; already folded into `onTime`, surfaced separately for
|
||||
consumers that differentiate.
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
required:
|
||||
- expected
|
||||
- expectedCount
|
||||
- due
|
||||
- taken
|
||||
- skipped
|
||||
- onTime
|
||||
- late
|
||||
- veryLate
|
||||
additionalProperties: false
|
||||
description: Per-day compliance cell with the timing breakdown that drives the history glyph track.
|
||||
ComplianceDisplay:
|
||||
type: object
|
||||
properties:
|
||||
@@ -9206,6 +9166,106 @@ components:
|
||||
additionalProperties: false
|
||||
description: The two-row card block whose windows scale with dosing cadence (dense meds keep 7 / 30 days, sparse meds
|
||||
step both windows up). NOT the 30-day denominator — read `compliance30.totalExpected` for that.
|
||||
GetMedicationComplianceResponse:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/MedicationComplianceResponse"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
MedicationComplianceResponse:
|
||||
type: object
|
||||
properties:
|
||||
compliance7:
|
||||
$ref: "#/components/schemas/ComplianceResult"
|
||||
compliance30:
|
||||
$ref: "#/components/schemas/ComplianceResult"
|
||||
dailyCompliance:
|
||||
type: object
|
||||
propertyNames:
|
||||
type: string
|
||||
additionalProperties:
|
||||
$ref: "#/components/schemas/DailyComplianceEntry"
|
||||
description: Flat per-day map keyed `YYYY-MM-DD` in the user timezone, one entry per day for up to 90 days back, clamped
|
||||
to the medication's `createdAt` (so a recently-created med has fewer entries). No weekly/monthly collapse —
|
||||
this is the raw daily grid.
|
||||
complianceDisplay:
|
||||
$ref: "#/components/schemas/ComplianceDisplay"
|
||||
required:
|
||||
- compliance7
|
||||
- compliance30
|
||||
- dailyCompliance
|
||||
- complianceDisplay
|
||||
additionalProperties: false
|
||||
description: Adherence read for a single medication. `compliance30` is the authoritative 30-day taken-vs-expected
|
||||
summary; `dailyCompliance` is the per-day grid for the history glyph track. The graded raw→week→month→year
|
||||
series used elsewhere for AI prompts does NOT apply here — this response is never downsampled.
|
||||
DailyComplianceEntry:
|
||||
type: object
|
||||
properties:
|
||||
expected:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
description: Engine-computed due-slot count for the day. Equals `expectedCount`; kept for existing consumers.
|
||||
expectedCount:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
description: True due-slot count for the day (additive field clients key off so they don't infer due-ness from
|
||||
`expected`).
|
||||
due:
|
||||
type: boolean
|
||||
description: "`expectedCount > 0`. Paint a per-day glyph as expected/missed ONLY when `due === true`; off-cadence /
|
||||
pre-creation / PRN days are not misses."
|
||||
taken:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
skipped:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
onTime:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
description: Doses taken in the on-time band, including the `early` bucket (early counts as compliant).
|
||||
late:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
veryLate:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
early:
|
||||
description: Doses taken before the on-time band's grace start; already folded into `onTime`, surfaced separately for
|
||||
consumers that differentiate.
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
required:
|
||||
- expected
|
||||
- expectedCount
|
||||
- due
|
||||
- taken
|
||||
- skipped
|
||||
- onTime
|
||||
- late
|
||||
- veryLate
|
||||
additionalProperties: false
|
||||
description: Per-day compliance cell with the timing breakdown that drives the history glyph track.
|
||||
ListScheduleRevisionsResponse:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -29,16 +29,14 @@ vi.mock("@/lib/db", () => ({
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/medications/route-guards", () => ({
|
||||
assertMedicationOwnership: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
// v1.15.20 — the route reads through the per-user compliance cache and a
|
||||
// per-user rate limit. Pass-through mocks keep each test's fixture
|
||||
// isolated (the real module-scope cache would leak state across tests);
|
||||
// the rate-limit tests below flip `allowed` explicitly.
|
||||
// the rate-limit tests below flip `allowed` explicitly. v1.16.8 — the
|
||||
// read is SWR (`cachedSwr`) and the ownership guard lives INSIDE the
|
||||
// builder, so the pass-through exercises the guard on every call.
|
||||
vi.mock("@/lib/cache/server-cache", () => ({
|
||||
cached: vi.fn(
|
||||
cachedSwr: vi.fn(
|
||||
async (_cache: unknown, _key: string, builder: () => Promise<unknown>) =>
|
||||
builder(),
|
||||
),
|
||||
@@ -71,7 +69,7 @@ vi.mock("next/headers", () => ({
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { cached } from "@/lib/cache/server-cache";
|
||||
import { cachedSwr } from "@/lib/cache/server-cache";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { getUserTodayBounds } from "@/lib/tz/local-day";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
@@ -130,7 +128,7 @@ beforeEach(() => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
// `resetAllMocks` clears the factory implementations — re-prime the
|
||||
// pass-through cache and the allowing rate limit for every test.
|
||||
vi.mocked(cached).mockImplementation(
|
||||
vi.mocked(cachedSwr).mockImplementation(
|
||||
async (_cache: unknown, _key: string, builder: () => Promise<unknown>) =>
|
||||
builder(),
|
||||
);
|
||||
@@ -436,8 +434,8 @@ describe("GET /api/medications/[id]/compliance — caching and limits", () => {
|
||||
const res = await GET(new Request("http://localhost"), ROUTE_PARAMS);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(vi.mocked(cached)).toHaveBeenCalledTimes(1);
|
||||
const [, key] = vi.mocked(cached).mock.calls[0];
|
||||
expect(vi.mocked(cachedSwr)).toHaveBeenCalledTimes(1);
|
||||
const [, key] = vi.mocked(cachedSwr).mock.calls[0];
|
||||
// `${userId}|...` prefix is what `invalidateUserMedications` sweeps.
|
||||
// The trailing tz segment (v1.16.1) makes a timezone change miss the
|
||||
// old entry instead of serving day buckets computed for the prior zone.
|
||||
@@ -471,3 +469,51 @@ describe("GET /api/medications/[id]/compliance — caching and limits", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// v1.16.8 — the ownership guard + medication read moved INSIDE the cache
|
||||
// builder: the key is `${userId}|${medicationId}|…`, so a warm hit is by
|
||||
// construction a payload this caller built for a medication they own, and
|
||||
// the pre-cache session→rate-limit path no longer pays two row reads per
|
||||
// card. The guard itself still runs on every cold build.
|
||||
describe("GET /api/medications/[id]/compliance — ownership inside the builder", () => {
|
||||
it("returns the sealed 404 when the medication belongs to another user", async () => {
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
|
||||
...medication([]),
|
||||
userId: "user-2",
|
||||
} as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
|
||||
const res = await GET(new Request("http://localhost"), ROUTE_PARAMS);
|
||||
expect(res.status).toBe(404);
|
||||
const body = await res.json();
|
||||
// Same envelope as the absent case — the existence channel stays sealed.
|
||||
expect(body.error).toBe("Medication not found");
|
||||
});
|
||||
|
||||
it("returns the sealed 404 when the medication does not exist", async () => {
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(null as never);
|
||||
|
||||
const res = await GET(new Request("http://localhost"), ROUTE_PARAMS);
|
||||
expect(res.status).toBe(404);
|
||||
const body = await res.json();
|
||||
expect(body.error).toBe("Medication not found");
|
||||
});
|
||||
|
||||
it("never touches the medication tables on a warm cache hit", async () => {
|
||||
// Simulate a warm cell: the SWR read resolves without invoking the
|
||||
// builder, so neither the medication nor the intake table is read.
|
||||
vi.mocked(cachedSwr).mockResolvedValue({
|
||||
compliance7: { rate: 100 },
|
||||
compliance30: { rate: 100 },
|
||||
dailyCompliance: {},
|
||||
complianceDisplay: { shortDays: 7, longDays: 30 },
|
||||
} as never);
|
||||
|
||||
const res = await GET(new Request("http://localhost"), ROUTE_PARAMS);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.medication.findUnique).not.toHaveBeenCalled();
|
||||
expect(prisma.medicationIntakeEvent.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -3,234 +3,41 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import {
|
||||
buildComplianceMedicationContext,
|
||||
buildMedicationComplianceBundle,
|
||||
lastNonSkippedTakenAt,
|
||||
type ComplianceDisplay,
|
||||
type ComplianceResult,
|
||||
type DailyComplianceEntry,
|
||||
} from "@/lib/analytics/compliance";
|
||||
import type { DoseHistoryRow } from "@/lib/medications/scheduling/dose-history";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
buildCompliancePayload,
|
||||
complianceCacheKey,
|
||||
type CompliancePayload,
|
||||
} from "@/lib/medications/compliance-payload";
|
||||
import {
|
||||
cachedSwr,
|
||||
caches,
|
||||
type ServerCache,
|
||||
} from "@/lib/cache/server-cache";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Lower bound for the intake-event read. The widest window any served
|
||||
* block needs is the 365-day display rung; one extra day absorbs the
|
||||
* inclusive-boundary edge so the ledger's own 365-day clamp never sits
|
||||
* on a half-fetched day.
|
||||
* Builder-internal sentinel for "no such medication for this caller".
|
||||
* Thrown from inside the cache builder so a rejected build is never
|
||||
* persisted; the handler maps it onto the canonical 404 envelope (the
|
||||
* same "Medication not found" shape `assertMedicationOwnership` mints,
|
||||
* so the existence channel stays sealed).
|
||||
*/
|
||||
const EVENT_FETCH_WINDOW_DAYS = 366;
|
||||
|
||||
/** The cached response body (the exact public wire shape). */
|
||||
interface CompliancePayload {
|
||||
compliance7: ComplianceResult;
|
||||
compliance30: ComplianceResult;
|
||||
dailyCompliance: Record<string, DailyComplianceEntry>;
|
||||
complianceDisplay: ComplianceDisplay;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.15.18 — fold one dose-history ledger row into a per-day heatmap entry.
|
||||
*
|
||||
* Slot rows contribute to `expected` / `expectedCount` (the day's due-slot
|
||||
* count): a taken slot adds to `taken` and its timing bucket; a missed slot
|
||||
* stays uncounted in `taken`; a skipped slot lands in `skipped`; an upcoming
|
||||
* slot is still due but not yet acted on. An ad-hoc row is a real off-schedule
|
||||
* take — it counts as `taken` AND adds its own `expected` slot (so the
|
||||
* heatmap's `missed = expected − taken − skipped` math stays non-negative) and
|
||||
* reads on-time (a logged dose colours green).
|
||||
*/
|
||||
function bucketLedgerRow(
|
||||
entry: DailyComplianceEntry,
|
||||
row: DoseHistoryRow,
|
||||
): void {
|
||||
switch (row.status) {
|
||||
case "taken_on_time":
|
||||
entry.expected++;
|
||||
entry.expectedCount++;
|
||||
entry.taken++;
|
||||
entry.onTime++;
|
||||
break;
|
||||
case "taken_late":
|
||||
entry.expected++;
|
||||
entry.expectedCount++;
|
||||
entry.taken++;
|
||||
entry.late++;
|
||||
break;
|
||||
case "missed":
|
||||
entry.expected++;
|
||||
entry.expectedCount++;
|
||||
break;
|
||||
case "skipped":
|
||||
entry.expected++;
|
||||
entry.expectedCount++;
|
||||
entry.skipped++;
|
||||
break;
|
||||
case "upcoming":
|
||||
// A future / still-takeable slot is due but not yet acted on. It counts
|
||||
// toward the day's expected/due grid but not toward taken or missed.
|
||||
entry.expected++;
|
||||
entry.expectedCount++;
|
||||
break;
|
||||
case "ad_hoc":
|
||||
// An off-schedule take: a real taken dose with no scheduled slot. Count
|
||||
// it as taken + its own expected slot so the heatmap missed math holds.
|
||||
entry.expected++;
|
||||
entry.expectedCount++;
|
||||
entry.taken++;
|
||||
entry.onTime++;
|
||||
break;
|
||||
class MedicationNotFoundError extends Error {
|
||||
constructor() {
|
||||
super("Medication not found");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The full compliance computation for one medication. Lifted out of the
|
||||
* handler so the server cache can wrap it: ONE bounded intake-event read
|
||||
* (only the four columns the math consumes, floored at the 366-day fetch
|
||||
* window) feeds ONE shared band-expansion pass
|
||||
* (`buildMedicationComplianceBundle`) that serves the 7-/30-day blocks,
|
||||
* the cadence-scaled display rows AND the 90-day heatmap — replacing the
|
||||
* historical five-plus per-request expansions over an unbounded,
|
||||
* all-columns event read.
|
||||
*/
|
||||
async function buildCompliancePayload(
|
||||
medication: {
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
startsOn: Date | null;
|
||||
endsOn: Date | null;
|
||||
oneShot: boolean;
|
||||
schedules: Parameters<typeof buildMedicationComplianceBundle>[1];
|
||||
},
|
||||
userId: string,
|
||||
userTz: string,
|
||||
): Promise<CompliancePayload> {
|
||||
// v1.15.9 — pin a single `now` and thread it into every cadence
|
||||
// computation so no block can straddle a day boundary on a slow request.
|
||||
const now = new Date();
|
||||
const createdAt = medication.createdAt;
|
||||
|
||||
const fetchFrom = new Date(
|
||||
Math.max(
|
||||
createdAt.getTime(),
|
||||
now.getTime() - EVENT_FETCH_WINDOW_DAYS * DAY_MS,
|
||||
),
|
||||
);
|
||||
const events = await prisma.medicationIntakeEvent.findMany({
|
||||
// v1.7.0 sync — exclude tombstoned rows from the compliance read.
|
||||
// Bounded to the 366-day fetch window: every served block is a suffix
|
||||
// of the 365-day ledger window, so older rows can never change the
|
||||
// response.
|
||||
where: {
|
||||
medicationId: medication.id,
|
||||
userId,
|
||||
deletedAt: null,
|
||||
scheduledFor: { gte: fetchFrom },
|
||||
},
|
||||
orderBy: { scheduledFor: "desc" },
|
||||
select: {
|
||||
takenAt: true,
|
||||
skipped: true,
|
||||
scheduledFor: true,
|
||||
// v1.15.9 — a forgotten dose the auto-miss cron flipped counts as a
|
||||
// miss, not a neutral skip.
|
||||
autoMissed: true,
|
||||
// v1.15.20 — pinned takes bind by anchor in the unified ledger so the
|
||||
// % agrees with the history view on a "zugeordnet" dose (taken-late).
|
||||
attributionSource: true,
|
||||
},
|
||||
});
|
||||
|
||||
// v1.7.0 SB-SCHED-2 — thread the medication context so the denominator
|
||||
// routes through the canonical engine (RRULE / rolling / one-shot / PRN /
|
||||
// cyclic). `lastIntakeAt` is the latest non-skipped takenAt inside the
|
||||
// fetch window (rolling cadences re-anchor on it).
|
||||
const lastIntakeAt = lastNonSkippedTakenAt(events);
|
||||
const medicationContext = buildComplianceMedicationContext(
|
||||
medication,
|
||||
lastIntakeAt,
|
||||
userTz,
|
||||
);
|
||||
|
||||
// ONE shared expansion pass for every served block (the v1.15.20
|
||||
// performance fix): bands + ledger + timeline are minted once over the
|
||||
// widest window and each sub-window is a tally over the same rows.
|
||||
const bundle = buildMedicationComplianceBundle(
|
||||
events,
|
||||
medication.schedules,
|
||||
medicationContext,
|
||||
now,
|
||||
);
|
||||
|
||||
// v1.15.18 — the heatmap / line-chart daily map is bucketed from the ONE
|
||||
// unified dose-history ledger (the same bands the % + the history view
|
||||
// read), so the per-day timing split (on-time / late) and the per-day
|
||||
// missed marks can never disagree with the headline rate. The 90-day
|
||||
// window is carved out of the shared ledger by `row.at`; the window
|
||||
// floor clamps to the medication's creation so pre-existence days never
|
||||
// mint phantom slots.
|
||||
const heatmapFrom = new Date(
|
||||
Math.max(now.getTime() - 90 * DAY_MS, createdAt.getTime()),
|
||||
);
|
||||
|
||||
const dailyCompliance: Record<string, DailyComplianceEntry> = {};
|
||||
const byDay = new Map<string, DailyComplianceEntry>();
|
||||
const ensureDay = (key: string): DailyComplianceEntry => {
|
||||
let entry = byDay.get(key);
|
||||
if (!entry) {
|
||||
entry = {
|
||||
expected: 0,
|
||||
expectedCount: 0,
|
||||
due: false,
|
||||
taken: 0,
|
||||
skipped: 0,
|
||||
onTime: 0,
|
||||
late: 0,
|
||||
veryLate: 0,
|
||||
early: 0,
|
||||
};
|
||||
byDay.set(key, entry);
|
||||
}
|
||||
return entry;
|
||||
};
|
||||
|
||||
// Bucket each ledger row into its user-tz day. A slot row buckets on its
|
||||
// anchor; an ad-hoc row on its real take time.
|
||||
for (const row of bundle.ledgerRows) {
|
||||
if (row.at.getTime() < heatmapFrom.getTime()) continue;
|
||||
const key = userDayKey(row.at, userTz);
|
||||
const entry = ensureDay(key);
|
||||
bucketLedgerRow(entry, row);
|
||||
}
|
||||
|
||||
for (const [key, entry] of byDay) {
|
||||
entry.due = entry.expectedCount > 0;
|
||||
dailyCompliance[key] = entry;
|
||||
}
|
||||
|
||||
return {
|
||||
compliance7: bundle.compliance7,
|
||||
compliance30: bundle.compliance30,
|
||||
dailyCompliance,
|
||||
complianceDisplay: bundle.complianceDisplay,
|
||||
};
|
||||
}
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (_request: Request, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
// The medications list fans this endpoint out once per card, so the
|
||||
// per-user budget is generous — it only caps a runaway client loop.
|
||||
// The per-user budget is generous — it only caps a runaway client
|
||||
// loop (the cards read the batched `/api/medications/compliance`
|
||||
// sibling; this endpoint serves the detail page).
|
||||
const rl = await checkRateLimit(
|
||||
`medication-compliance:${user.id}`,
|
||||
60,
|
||||
@@ -240,39 +47,50 @@ export const GET = apiHandler(
|
||||
return apiError("Too many compliance requests. Please retry later.", 429);
|
||||
}
|
||||
|
||||
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper.
|
||||
const guard = await assertMedicationOwnership(id, user.id);
|
||||
if (guard) return guard;
|
||||
|
||||
const medication = await prisma.medication.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
schedules: true,
|
||||
// v1.16.3 — archived schedule eras for era-aware compliance.
|
||||
scheduleRevisions: { orderBy: { validFrom: "asc" } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!medication) {
|
||||
return apiError("Medication not found", 404);
|
||||
}
|
||||
|
||||
const userTz = user.timezone || "Europe/Berlin";
|
||||
|
||||
// Read-through the per-user compliance cache (15 min TTL). Every
|
||||
// intake / medication write flushes the `${userId}|` prefix via
|
||||
// `invalidateUserMedications`, so a warm entry can only be stale
|
||||
// relative to wall-clock drift, never to a user action. The key
|
||||
// carries the timezone the payload was bucketed in: day keys, the
|
||||
// heatmap, and the overdue cut all derive from `userTz`, so a
|
||||
// timezone change must miss the old entry instead of serving day
|
||||
// buckets computed for the previous zone until the TTL lapses.
|
||||
const payload = await cached(
|
||||
caches.medicationCompliance as ServerCache<CompliancePayload>,
|
||||
`${user.id}|${id}|compliance|${userTz}`,
|
||||
() => buildCompliancePayload(medication, user.id, userTz),
|
||||
annotate,
|
||||
);
|
||||
// Read-through the per-user compliance cache (15 min fresh TTL +
|
||||
// SWR window). The ownership guard and the medication read live
|
||||
// INSIDE the builder: the cache key is `${userId}|${medicationId}|…`,
|
||||
// so a warm or stale hit is by construction a payload this caller
|
||||
// already built for a medication they own — re-running the guard on
|
||||
// every hit only re-paid two row reads per card. A cross-user id
|
||||
// misses this user's prefix, runs the builder, and 404s there.
|
||||
//
|
||||
// Interactive intake / medication writes EVICT the `${userId}|`
|
||||
// prefix via `invalidateUserMedications({ evict: true })`, so a warm
|
||||
// entry can only be stale relative to wall-clock drift, never to the
|
||||
// user's own action; background sync paths mark the bucket stale and
|
||||
// the SWR read serves the prior payload while one coalesced rebuild
|
||||
// warms a fresh one.
|
||||
let payload: CompliancePayload;
|
||||
try {
|
||||
payload = await cachedSwr(
|
||||
caches.medicationCompliance as ServerCache<CompliancePayload>,
|
||||
complianceCacheKey(user.id, id, userTz),
|
||||
async () => {
|
||||
const medication = await prisma.medication.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
schedules: true,
|
||||
// v1.16.3 — archived schedule eras for era-aware compliance.
|
||||
scheduleRevisions: { orderBy: { validFrom: "asc" } },
|
||||
},
|
||||
});
|
||||
// Privacy gate — same sealed 404 for "absent" and "not yours".
|
||||
if (!medication || medication.userId !== user.id) {
|
||||
throw new MedicationNotFoundError();
|
||||
}
|
||||
return buildCompliancePayload(medication, user.id, userTz);
|
||||
},
|
||||
annotate,
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof MedicationNotFoundError) {
|
||||
return apiError("Medication not found", 404);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
|
||||
@@ -285,7 +285,7 @@ export const PUT = apiHandler(
|
||||
|
||||
// v1.4.34 IW-G — bust per-user medications + compliance + achievement
|
||||
// caches so the next read reflects the edited event.
|
||||
invalidateUserMedications(user.id);
|
||||
invalidateUserMedications(user.id, { evict: true });
|
||||
|
||||
// v1.4.39 W-MED — refresh the rollup row for both the old + new
|
||||
// scheduledFor day-keys when the dose moved across a day boundary
|
||||
@@ -364,7 +364,7 @@ export const DELETE = apiHandler(
|
||||
|
||||
// v1.4.34 IW-G — bust per-user medications + compliance + achievement
|
||||
// caches so the next read reflects the deletion.
|
||||
invalidateUserMedications(user.id);
|
||||
invalidateUserMedications(user.id, { evict: true });
|
||||
|
||||
// v1.4.39 W-MED — refresh the rollup row for the day the deleted
|
||||
// event sat on. When that day now holds zero events the helper
|
||||
|
||||
@@ -180,7 +180,7 @@ export const POST = apiHandler(
|
||||
meta: { deleted_count: count, day_buckets: dayKeysToRefresh.size },
|
||||
});
|
||||
|
||||
invalidateUserMedications(user.id);
|
||||
invalidateUserMedications(user.id, { evict: true });
|
||||
|
||||
return apiSuccess({ deleted: count });
|
||||
},
|
||||
|
||||
@@ -163,7 +163,7 @@ export const POST = apiHandler(
|
||||
// (list, intake caches, compliance payload). This write path was the
|
||||
// one intake surface missing the flush.
|
||||
if (imported > 0) {
|
||||
invalidateUserMedications(user.id);
|
||||
invalidateUserMedications(user.id, { evict: true });
|
||||
}
|
||||
|
||||
await auditLog("medication.intake.import", {
|
||||
|
||||
@@ -89,7 +89,11 @@ describe("DELETE /api/medications/[id]/intake/purge", () => {
|
||||
|
||||
it("invalidates the user medication caches on success (F-1 C-3)", async () => {
|
||||
await DELETE(deleteReq(), ROUTE_PARAMS);
|
||||
expect(invalidateUserMedications).toHaveBeenCalledWith("user-1");
|
||||
// v1.16.8 — the purge is an interactive write, so it hard-evicts the
|
||||
// SWR buckets instead of marking them stale.
|
||||
expect(invalidateUserMedications).toHaveBeenCalledWith("user-1", {
|
||||
evict: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the 404 from the shared ownership helper without invalidating", async () => {
|
||||
|
||||
@@ -64,7 +64,7 @@ export const DELETE = apiHandler(
|
||||
// post-purge counts rather than reading stale values for the
|
||||
// TTL of each cache. Matches the sibling routes (POST intake,
|
||||
// PUT medication, bulk-delete) that all fire this bundle.
|
||||
invalidateUserMedications(user.id);
|
||||
invalidateUserMedications(user.id, { evict: true });
|
||||
|
||||
return apiSuccess({ purged: true, count });
|
||||
},
|
||||
|
||||
@@ -467,7 +467,7 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
|
||||
|
||||
// v1.4.34 IW-G — bust per-user medications + compliance + achievement
|
||||
// caches so the next read reflects the dose event.
|
||||
invalidateUserMedications(user.id);
|
||||
invalidateUserMedications(user.id, { evict: true });
|
||||
|
||||
// v1.4.39 W-MED — refresh the persistent compliance rollup for the
|
||||
// affected day. The hook is best-effort; failures annotate but never
|
||||
@@ -488,7 +488,7 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
|
||||
// actually receive its dose.
|
||||
const reconcileAction = await reconcileOneShotState(prisma, id, user.id);
|
||||
if (reconcileAction !== "noop") {
|
||||
invalidateUserMedications(user.id);
|
||||
invalidateUserMedications(user.id, { evict: true });
|
||||
}
|
||||
|
||||
return apiSuccess(event, 201);
|
||||
|
||||
@@ -618,7 +618,7 @@ export const PUT = apiHandler(
|
||||
|
||||
// v1.4.34 IW-G — bust per-user medications + compliance + achievement
|
||||
// caches so the next read reflects the schedule change.
|
||||
invalidateUserMedications(user.id);
|
||||
invalidateUserMedications(user.id, { evict: true });
|
||||
|
||||
return apiSuccess({
|
||||
...medication,
|
||||
@@ -675,7 +675,7 @@ export const DELETE = apiHandler(
|
||||
|
||||
// v1.4.34 IW-G — bust per-user medications + compliance + achievement
|
||||
// caches so the next read reflects the deletion.
|
||||
invalidateUserMedications(user.id);
|
||||
invalidateUserMedications(user.id, { evict: true });
|
||||
|
||||
return apiSuccess({ deleted: true });
|
||||
},
|
||||
|
||||
@@ -31,6 +31,9 @@ vi.mock("@/lib/cache/server-cache", () => ({
|
||||
cached: vi.fn(async (_c: unknown, _k: string, f: () => Promise<unknown>) =>
|
||||
f(),
|
||||
),
|
||||
cachedSwr: vi.fn(async (_c: unknown, _k: string, f: () => Promise<unknown>) =>
|
||||
f(),
|
||||
),
|
||||
caches: { medicationsList: {} },
|
||||
__resetAllCachesForTests: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* v1.16.8 — batched card-compliance read (`GET /api/medications/compliance`).
|
||||
*
|
||||
* One round trip returns a compact adherence row per medication the
|
||||
* caller owns, replacing the per-card fan-out over the per-id endpoint.
|
||||
* The rows build through the SAME per-medication cache cells
|
||||
* (`${userId}|${medicationId}|compliance|${userTz}`) the per-id route
|
||||
* reads, so the two endpoints warm each other and invalidate together.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
medication: { findMany: vi.fn() },
|
||||
medicationIntakeEvent: { findMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/cache/server-cache", () => ({
|
||||
cachedSwr: vi.fn(
|
||||
async (_cache: unknown, _key: string, builder: () => Promise<unknown>) =>
|
||||
builder(),
|
||||
),
|
||||
caches: { medicationCompliance: {} },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 29,
|
||||
resetAt: Date.now() + 60_000,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { cachedSwr } from "@/lib/cache/server-cache";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const TZ = "UTC";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "user-1",
|
||||
username: "testuser",
|
||||
role: "USER" as const,
|
||||
timezone: TZ,
|
||||
},
|
||||
};
|
||||
|
||||
function dailySchedule(id: string) {
|
||||
return {
|
||||
id,
|
||||
windowStart: "08:00",
|
||||
windowEnd: "09:00",
|
||||
timesOfDay: ["08:00"],
|
||||
daysOfWeek: null,
|
||||
rrule: "FREQ=DAILY",
|
||||
rollingIntervalDays: null,
|
||||
reminderGraceMinutes: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
};
|
||||
}
|
||||
|
||||
function medication(id: string) {
|
||||
return {
|
||||
id,
|
||||
userId: "user-1",
|
||||
createdAt: new Date(Date.now() - 200 * 24 * 60 * 60 * 1000),
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
schedules: [dailySchedule(`${id}-sched`)],
|
||||
scheduleRevisions: [],
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(cachedSwr).mockImplementation(
|
||||
async (_cache: unknown, _key: string, builder: () => Promise<unknown>) =>
|
||||
builder(),
|
||||
);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 29,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
});
|
||||
|
||||
describe("GET /api/medications/compliance", () => {
|
||||
it("returns one compact row per medication, without the heatmap grid", async () => {
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([
|
||||
medication("med-1"),
|
||||
medication("med-2"),
|
||||
] as never);
|
||||
|
||||
const res = await (GET as (req: Request) => Promise<Response>)(
|
||||
new Request("http://localhost"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
const items = body.data as Array<Record<string, unknown>>;
|
||||
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items.map((i) => i.medicationId)).toEqual(["med-1", "med-2"]);
|
||||
for (const item of items) {
|
||||
expect(item.compliance7).toBeDefined();
|
||||
expect(item.compliance30).toBeDefined();
|
||||
expect(item.complianceDisplay).toBeDefined();
|
||||
// The heavy per-day grid stays on the per-id endpoint.
|
||||
expect(item).not.toHaveProperty("dailyCompliance");
|
||||
}
|
||||
});
|
||||
|
||||
it("reads each medication through the shared per-medication cache cell", async () => {
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([
|
||||
medication("med-1"),
|
||||
medication("med-2"),
|
||||
] as never);
|
||||
|
||||
const res = await (GET as (req: Request) => Promise<Response>)(
|
||||
new Request("http://localhost"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// Same key shape as the per-id route, so the two endpoints warm each
|
||||
// other and `invalidateUserMedications` sweeps both with one prefix.
|
||||
const keys = vi.mocked(cachedSwr).mock.calls.map(([, key]) => key);
|
||||
expect(keys).toEqual([
|
||||
"user-1|med-1|compliance|UTC",
|
||||
"user-1|med-2|compliance|UTC",
|
||||
]);
|
||||
});
|
||||
|
||||
it("scopes the medication read to the calling user", async () => {
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
|
||||
|
||||
const res = await (GET as (req: Request) => Promise<Response>)(
|
||||
new Request("http://localhost"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data).toEqual([]);
|
||||
|
||||
const call = vi.mocked(prisma.medication.findMany).mock.calls[0][0] as {
|
||||
where: { userId: string };
|
||||
};
|
||||
expect(call.where.userId).toBe("user-1");
|
||||
});
|
||||
|
||||
it("returns 429 when the per-user rate limit is exhausted", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
|
||||
const res = await (GET as (req: Request) => Promise<Response>)(
|
||||
new Request("http://localhost"),
|
||||
);
|
||||
expect(res.status).toBe(429);
|
||||
expect(vi.mocked(checkRateLimit)).toHaveBeenCalledWith(
|
||||
"medication-compliance-summary:user-1",
|
||||
30,
|
||||
60_000,
|
||||
);
|
||||
expect(prisma.medication.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import {
|
||||
buildCompliancePayload,
|
||||
complianceCacheKey,
|
||||
type CompliancePayload,
|
||||
} from "@/lib/medications/compliance-payload";
|
||||
import {
|
||||
cachedSwr,
|
||||
caches,
|
||||
type ServerCache,
|
||||
} from "@/lib/cache/server-cache";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
/**
|
||||
* Batched card-compliance read: one round trip for every medication the
|
||||
* caller owns, replacing the per-card fan-out over
|
||||
* `GET /api/medications/{id}/compliance` (one request per card, ~1 s
|
||||
* cold each). The per-medication payload still builds and caches through
|
||||
* the SAME `medicationCompliance` cells the per-id route reads, so this
|
||||
* read warms the detail page (and vice versa) and both invalidate
|
||||
* together on a write.
|
||||
*
|
||||
* The heavy `dailyCompliance` heatmap map is intentionally NOT on this
|
||||
* wire shape — the cards render rates / streak / display block only; the
|
||||
* detail page keeps the per-id route for the grid.
|
||||
*/
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`medication-compliance-summary:${user.id}`,
|
||||
30,
|
||||
60_000,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many compliance requests. Please retry later.", 429);
|
||||
}
|
||||
|
||||
const userTz = user.timezone || "Europe/Berlin";
|
||||
|
||||
// Same ordering as the medications list so the page's card order and
|
||||
// this payload walk the same sequence.
|
||||
const medications = await prisma.medication.findMany({
|
||||
where: { userId: user.id },
|
||||
include: {
|
||||
schedules: true,
|
||||
// v1.16.3 — archived schedule eras for era-aware compliance.
|
||||
scheduleRevisions: { orderBy: { validFrom: "asc" } },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
// Sequential on purpose: each cold cell costs one bounded intake read +
|
||||
// one band-expansion pass, and a sequential walk keeps a many-meds
|
||||
// account from stampeding the pool. Warm / stale cells return without
|
||||
// touching the database at all.
|
||||
const items = [];
|
||||
for (const medication of medications) {
|
||||
const payload = await cachedSwr(
|
||||
caches.medicationCompliance as ServerCache<CompliancePayload>,
|
||||
complianceCacheKey(user.id, medication.id, userTz),
|
||||
() => buildCompliancePayload(medication, user.id, userTz),
|
||||
annotate,
|
||||
);
|
||||
items.push({
|
||||
medicationId: medication.id,
|
||||
compliance7: payload.compliance7,
|
||||
compliance30: payload.compliance30,
|
||||
complianceDisplay: payload.complianceDisplay,
|
||||
});
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "medication.compliance_summary.read" },
|
||||
meta: { count: items.length },
|
||||
});
|
||||
|
||||
return apiSuccess(items);
|
||||
});
|
||||
@@ -569,7 +569,11 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
|
||||
// v1.4.34 IW-G — bust per-user medications + compliance + achievement
|
||||
// caches when at least one row landed so the next read reflects the
|
||||
// ingested batch.
|
||||
// ingested batch. v1.16.8 — this is the background sync path (iOS bulk
|
||||
// ingest), so the SWR buckets are marked stale rather than hard-evicted:
|
||||
// the readers keep serving the prior list / compliance payloads while
|
||||
// one coalesced recompute warms each cell, instead of a high-frequency
|
||||
// sync busting every card into an inline cold rebuild.
|
||||
if (inserted > 0 || updated > 0) {
|
||||
invalidateUserMedications(user.id);
|
||||
}
|
||||
|
||||
@@ -599,7 +599,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
// v1.4.34 IW-G — bust the medications + compliance + achievement
|
||||
// caches for this user so the next read reflects the dose change.
|
||||
invalidateUserMedications(user.id);
|
||||
invalidateUserMedications(user.id, { evict: true });
|
||||
|
||||
// v1.4.39 W-MED — refresh the persistent compliance rollup row for
|
||||
// the affected day so the next read after the cache miss returns
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
} from "@/lib/medications/scheduling/next-due";
|
||||
import { getUserTodayBounds } from "@/lib/tz/local-day";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
type MedicationsListResult = Array<Record<string, unknown>>;
|
||||
@@ -187,11 +187,16 @@ export const GET = apiHandler(async () => {
|
||||
|
||||
const userTz = user.timezone || "Europe/Berlin";
|
||||
|
||||
// Cache the list shape on userId. The 60s TTL bounds the cross-midnight
|
||||
// staleness window for `todayEventCount` (the only time-of-day-derived
|
||||
// field in the response); writes via POST/PUT/DELETE flush through
|
||||
// `invalidateUserMedications` before the next read lands.
|
||||
const result = await cached(
|
||||
// Cache the list shape on userId. The 60 s fresh TTL bounds the
|
||||
// cross-midnight staleness window for `todayEventCount` and the
|
||||
// next-due fields; v1.16.8 reads through `cachedSwr`, so a visit
|
||||
// minutes after the last one serves the prior list immediately while
|
||||
// one background recompute warms a fresh entry (the 10-minute stale
|
||||
// window bounds how old that served list can be). Interactive writes
|
||||
// via POST/PUT/DELETE hard-evict through
|
||||
// `invalidateUserMedications({ evict: true })` before the next read
|
||||
// lands; background sync paths mark the bucket stale instead.
|
||||
const result = await cachedSwr(
|
||||
caches.medications as ServerCache<MedicationsListResult>,
|
||||
user.id,
|
||||
() => buildMedicationsList(user.id, userTz),
|
||||
@@ -397,7 +402,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
// v1.4.34 IW-G — bust per-user medications + compliance + achievement
|
||||
// caches so the next read reflects the new schedule.
|
||||
invalidateUserMedications(user.id);
|
||||
invalidateUserMedications(user.id, { evict: true });
|
||||
|
||||
return apiSuccess(
|
||||
{
|
||||
|
||||
@@ -327,11 +327,20 @@ describe("streak-token parity — generic vs GLP-1 card", () => {
|
||||
],
|
||||
};
|
||||
|
||||
// v1.16.8 — the cards read ONE batched summary key and `select` their
|
||||
// own row, so seeding merges into the shared array.
|
||||
function seedStreak(client: QueryClient, medId: string) {
|
||||
client.setQueryData(["medications", medId, "compliance"], {
|
||||
compliance7: { rate: 90, streak: 4, totalExpected: 7, taken: 6 },
|
||||
compliance30: { rate: 88 },
|
||||
});
|
||||
const key = ["medications", "compliance-summary"];
|
||||
const existing =
|
||||
(client.getQueryData(key) as Array<{ medicationId: string }>) ?? [];
|
||||
client.setQueryData(key, [
|
||||
...existing.filter((row) => row.medicationId !== medId),
|
||||
{
|
||||
medicationId: medId,
|
||||
compliance7: { rate: 90, streak: 4, totalExpected: 7, taken: 6 },
|
||||
compliance30: { rate: 88 },
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
it("both cards render the flame with the canonical token, never the drift", () => {
|
||||
|
||||
@@ -140,13 +140,22 @@ function seedCompliance(
|
||||
streak?: number;
|
||||
} = {},
|
||||
) {
|
||||
client.setQueryData(["medications", medId, "compliance"], {
|
||||
compliance7: {
|
||||
rate: payload.rate7 ?? 85,
|
||||
streak: payload.streak ?? 0,
|
||||
// v1.16.8 — the cards read ONE batched summary key and `select` their
|
||||
// own row, so seeding merges into the shared array.
|
||||
const key = ["medications", "compliance-summary"];
|
||||
const existing =
|
||||
(client.getQueryData(key) as Array<{ medicationId: string }>) ?? [];
|
||||
client.setQueryData(key, [
|
||||
...existing.filter((row) => row.medicationId !== medId),
|
||||
{
|
||||
medicationId: medId,
|
||||
compliance7: {
|
||||
rate: payload.rate7 ?? 85,
|
||||
streak: payload.streak ?? 0,
|
||||
},
|
||||
compliance30: { rate: payload.rate30 ?? 82 },
|
||||
},
|
||||
compliance30: { rate: payload.rate30 ?? 82 },
|
||||
});
|
||||
]);
|
||||
}
|
||||
|
||||
function render(
|
||||
|
||||
@@ -34,11 +34,20 @@ function render(node: React.ReactNode, client: QueryClient) {
|
||||
);
|
||||
}
|
||||
|
||||
// v1.16.8 — the cards read ONE batched summary key and `select` their
|
||||
// own row, so seeding merges into the shared array.
|
||||
function seedCompliance(client: QueryClient, medId: string) {
|
||||
client.setQueryData(["medications", medId, "compliance"], {
|
||||
compliance7: { rate: 90, streak: 0, totalExpected: 7, taken: 6 },
|
||||
compliance30: { rate: 88 },
|
||||
});
|
||||
const key = ["medications", "compliance-summary"];
|
||||
const existing =
|
||||
(client.getQueryData(key) as Array<{ medicationId: string }>) ?? [];
|
||||
client.setQueryData(key, [
|
||||
...existing.filter((row) => row.medicationId !== medId),
|
||||
{
|
||||
medicationId: medId,
|
||||
compliance7: { rate: 90, streak: 0, totalExpected: 7, taken: 6 },
|
||||
compliance30: { rate: 88 },
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
// A schedule whose window has already passed for today (01:00–02:00) so
|
||||
|
||||
@@ -59,11 +59,21 @@ function render(
|
||||
);
|
||||
}
|
||||
|
||||
// v1.16.8 — the cards read ONE batched summary key and `select` their
|
||||
// own row, so seeding merges into the shared array instead of writing a
|
||||
// per-medication key.
|
||||
function seedCompliance(client: QueryClient, medId: string) {
|
||||
client.setQueryData(["medications", medId, "compliance"], {
|
||||
compliance7: { rate: 90, streak: 0, totalExpected: 7, taken: 6 },
|
||||
compliance30: { rate: 88 },
|
||||
});
|
||||
const key = ["medications", "compliance-summary"];
|
||||
const existing =
|
||||
(client.getQueryData(key) as Array<{ medicationId: string }>) ?? [];
|
||||
client.setQueryData(key, [
|
||||
...existing.filter((row) => row.medicationId !== medId),
|
||||
{
|
||||
medicationId: medId,
|
||||
compliance7: { rate: 90, streak: 0, totalExpected: 7, taken: 6 },
|
||||
compliance30: { rate: 88 },
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function seedGlp1Details(client: QueryClient, medId: string) {
|
||||
|
||||
@@ -379,9 +379,11 @@ describe("runUndoIntake — shared soft-delete", () => {
|
||||
|
||||
await runUndoIntake({ medication, eventId: "evt-1", t, queryClient });
|
||||
|
||||
// Method + path are the contract; the shared fetch wrapper may attach
|
||||
// transport details (e.g. an abort signal) the undo path doesn't own.
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/medications/med-1/intake/evt-1",
|
||||
{ method: "DELETE" },
|
||||
expect.objectContaining({ method: "DELETE" }),
|
||||
);
|
||||
expect(toast.success).toHaveBeenCalledWith("medications.intakeUndone");
|
||||
expect(queryClient.invalidateQueries).toHaveBeenCalled();
|
||||
|
||||
@@ -160,19 +160,24 @@ export function MedicationCardBody({
|
||||
{/* Top status line. The in-window take-now pill (success) and the
|
||||
overdue escalation are mutually exclusive — a dose is either still
|
||||
in its take-window or past it. The overdue line wins when present
|
||||
so a heavily-overdue dose reads loud at the top of the card. */}
|
||||
{overdueLabel ? (
|
||||
<p className="text-destructive flex items-center gap-1 text-sm font-medium">
|
||||
<AlertTriangle className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
{overdueLabel}
|
||||
</p>
|
||||
) : windowStatus ? (
|
||||
<MedicationStatusPill
|
||||
status={windowStatus.status}
|
||||
windowStart={windowStatus.windowStart}
|
||||
windowEnd={windowStatus.windowEnd}
|
||||
/>
|
||||
) : null}
|
||||
so a heavily-overdue dose reads loud at the top of the card.
|
||||
The slot keeps a constant one-line height even when empty: the
|
||||
overdue tier arrives with the compliance query, and a line
|
||||
popping in late used to shift every row below it (CLS). */}
|
||||
<div className="min-h-5">
|
||||
{overdueLabel ? (
|
||||
<p className="text-destructive flex items-center gap-1 text-sm font-medium">
|
||||
<AlertTriangle className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
{overdueLabel}
|
||||
</p>
|
||||
) : windowStatus ? (
|
||||
<MedicationStatusPill
|
||||
status={windowStatus.status}
|
||||
windowStart={windowStatus.windowStart}
|
||||
windowEnd={windowStatus.windowEnd}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Next / last intake — the two decisive lines, each shown once. */}
|
||||
<MedicationNextLastSlot next={nextLine} last={lastLine} />
|
||||
@@ -192,9 +197,14 @@ export function MedicationCardBody({
|
||||
<MedicationComplianceSkeleton />
|
||||
))}
|
||||
|
||||
{/* Open-cycle status line — calm, rate-decoupled. */}
|
||||
{active && currentCycle && (
|
||||
<MedicationCycleStatus cycle={currentCycle} />
|
||||
{/* Open-cycle status line — calm, rate-decoupled. The slot reserves
|
||||
one xs text line on every active card: the descriptor rides the
|
||||
compliance query, and a line appearing after the fetch resolved
|
||||
used to grow the card and shift the grid row (CLS). */}
|
||||
{active && (
|
||||
<div className="min-h-4">
|
||||
{currentCycle && <MedicationCycleStatus cycle={currentCycle} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick actions — bottom-pinned so the action rows align across a
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
toBerlinDate,
|
||||
} from "@/lib/medications/window-status";
|
||||
import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts/displayed-slot-instant";
|
||||
import type { ComplianceDisplay } from "@/lib/analytics/compliance";
|
||||
import { useMedicationComplianceSummary } from "@/lib/queries/use-medication-compliance-summary";
|
||||
|
||||
/**
|
||||
* v1.4.25 W4d — GLP-1 medication card variant.
|
||||
@@ -110,19 +110,6 @@ export interface Glp1Medication {
|
||||
schedules: ScheduleLite[];
|
||||
}
|
||||
|
||||
interface ComplianceData {
|
||||
compliance7: { rate: number; streak: number };
|
||||
compliance30: { rate: number };
|
||||
/**
|
||||
* v1.8.6 — the two compliance windows scaled to the dosing cadence. GLP-1
|
||||
* injections are commonly weekly, so this card's windows step up to the
|
||||
* 30-/90-day rung where the bars stay meaningful. Additive — older mocks
|
||||
* omit it, in which case the card falls back to the static 7-/30-day
|
||||
* fields.
|
||||
*/
|
||||
complianceDisplay?: ComplianceDisplay;
|
||||
}
|
||||
|
||||
interface DetailsResponse {
|
||||
doseChanges: DoseChangeLite[];
|
||||
recentIntakes: IntakeLite[];
|
||||
@@ -202,22 +189,10 @@ export function Glp1MedicationCard({
|
||||
},
|
||||
});
|
||||
|
||||
const { data: compliance } = useQuery({
|
||||
queryKey: queryKeys.medicationCompliance(medication.id),
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await apiGet<ComplianceData>(
|
||||
`/api/medications/${medication.id}/compliance`,
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
// v1.15.20 — same 5-minute window as the generic card: dose actions
|
||||
// invalidate the key explicitly, so a short staleTime only re-fired
|
||||
// a request per card on every list visit.
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
// v1.16.8 — same batched compliance source as the generic card: one
|
||||
// `GET /api/medications/compliance` round trip shared by every card on
|
||||
// the page (the per-id endpoint stays for the detail page's heatmap).
|
||||
const { data: compliance } = useMedicationComplianceSummary(medication.id);
|
||||
|
||||
// v1.4.37 W4b — same reminder-thresholds source as the generic
|
||||
// medication card so the take-now / overdue / very-overdue pill
|
||||
|
||||
@@ -23,10 +23,10 @@ import {
|
||||
queryKeys,
|
||||
} from "@/lib/query-keys";
|
||||
import { apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
import { useMedicationComplianceSummary } from "@/lib/queries/use-medication-compliance-summary";
|
||||
import { LogInjectionSiteDialog } from "@/components/medications/log-injection-site-dialog";
|
||||
import { useGlobalExcludedInjectionSites } from "@/lib/medications/use-injection-site-prefs";
|
||||
import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
|
||||
import type { ComplianceDisplay } from "@/lib/analytics/compliance";
|
||||
|
||||
interface Schedule {
|
||||
id: string;
|
||||
@@ -87,25 +87,6 @@ interface Medication {
|
||||
schedules: Schedule[];
|
||||
}
|
||||
|
||||
interface ComplianceData {
|
||||
compliance7: {
|
||||
rate: number;
|
||||
streak: number;
|
||||
};
|
||||
compliance30: {
|
||||
rate: number;
|
||||
};
|
||||
/**
|
||||
* v1.8.6 — the two compliance windows scaled to the dosing cadence. The
|
||||
* card always shows two percentage rows; `shortDays` / `longDays` name the
|
||||
* windows and `short` / `long` carry their rates. v1.15.9 also carries the
|
||||
* open dose's `currentDose.status` for the green / overdue presentation.
|
||||
* Additive — older mocks omit it, in which case the card falls back to the
|
||||
* static 7-/30-day `compliance7` / `compliance30` fields and a calm state.
|
||||
*/
|
||||
complianceDisplay?: ComplianceDisplay;
|
||||
}
|
||||
|
||||
interface MedicationCardProps {
|
||||
medication: Medication;
|
||||
onEdit: (med: Medication) => void;
|
||||
@@ -147,24 +128,10 @@ export function MedicationCard({
|
||||
},
|
||||
});
|
||||
|
||||
const { data: compliance } = useQuery({
|
||||
queryKey: queryKeys.medicationCompliance(medication.id),
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await apiGet<ComplianceData>(
|
||||
`/api/medications/${medication.id}/compliance`,
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
// v1.15.20 — the card renders two percentage rows that move on a
|
||||
// dose action (which invalidates this key through
|
||||
// `medicationDependentKeys`) or over hours of wall-clock drift; a
|
||||
// 30 s window only re-fired one request per card on every list
|
||||
// visit. Five minutes matches the reminder-thresholds query below.
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
// v1.16.8 — the per-card compliance read rides the ONE batched
|
||||
// `GET /api/medications/compliance` round trip every card on the page
|
||||
// shares (the per-id endpoint stays for the detail page's heatmap).
|
||||
const { data: compliance } = useMedicationComplianceSummary(medication.id);
|
||||
|
||||
const [, forceUpdate] = useReducer((x: number) => x + 1, 0);
|
||||
|
||||
|
||||
+51
@@ -213,6 +213,57 @@ describe("invalidateUserMedications", () => {
|
||||
caches.medicationCompliance.get(`${USER_B}|med-1|compliance`),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("marks the SWR buckets stale by default — background sync posture", async () => {
|
||||
await primeAllCaches();
|
||||
invalidateUserMedications(USER_A);
|
||||
|
||||
// v1.16.8 — the default (bulk sync / cron) path marks stale: the
|
||||
// entries are gone for plain `get()` (asserted above) but stay
|
||||
// serveable for the `cachedSwr` readers (medications list, both
|
||||
// compliance routes, the dashboard snapshot), so a high-frequency
|
||||
// iOS sync never busts every card into an inline cold rebuild.
|
||||
expect(caches.medications.getAllowStale(USER_A)).toEqual({
|
||||
value: { m: 1 },
|
||||
stale: true,
|
||||
});
|
||||
expect(
|
||||
caches.medicationCompliance.getAllowStale(`${USER_A}|med-1|compliance`),
|
||||
).toEqual({ value: { mc: 1 }, stale: true });
|
||||
expect(
|
||||
caches.analytics.getAllowStale(dashboardSnapshotCacheKey(USER_A)),
|
||||
).toEqual({ value: { snap: 1 }, stale: true });
|
||||
|
||||
// User B stays fresh.
|
||||
expect(caches.medications.getAllowStale(USER_B)).toEqual({
|
||||
value: { m: 2 },
|
||||
stale: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("hard-evicts the SWR buckets with { evict: true } — interactive write posture", async () => {
|
||||
await primeAllCaches();
|
||||
invalidateUserMedications(USER_A, { evict: true });
|
||||
|
||||
// v1.16.8 — an interactive take / skip / CRUD write must NOT leave a
|
||||
// stale-serveable body behind: the SWR readers would hand the user
|
||||
// back the pre-write card rates. The evict drops the entries.
|
||||
expect(caches.medications.getAllowStale(USER_A)).toBeNull();
|
||||
expect(
|
||||
caches.medicationCompliance.getAllowStale(`${USER_A}|med-1|compliance`),
|
||||
).toBeNull();
|
||||
expect(
|
||||
caches.analytics.getAllowStale(dashboardSnapshotCacheKey(USER_A)),
|
||||
).toBeNull();
|
||||
|
||||
// User B stays warm; the non-SWR buckets evict as before.
|
||||
expect(caches.medications.getAllowStale(USER_B)).not.toBeNull();
|
||||
expect(
|
||||
caches.medicationCompliance.getAllowStale(`${USER_B}|med-1|compliance`),
|
||||
).not.toBeNull();
|
||||
expect(caches.medicationsIntake.get(`${USER_A}|compliance|30`)).toBeNull();
|
||||
expect(caches.achievements.get(USER_A)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalidateUserDashboardWidgets", () => {
|
||||
|
||||
Vendored
+39
-12
@@ -117,24 +117,51 @@ export function invalidateUserMood(userId: string): void {
|
||||
* Invalidate every cache that may reflect a user's medication state.
|
||||
*
|
||||
* Covers the medications cache, the medication-intake compliance cache
|
||||
* (cached daily), and achievement progress.
|
||||
* (cached daily), the per-medication compliance payload, and achievement
|
||||
* progress.
|
||||
*
|
||||
* v1.16.8 — `evict` splits the SWR buckets (medications, the
|
||||
* per-medication compliance payload, analytics) by write origin, the
|
||||
* `invalidateUserMeasurements` pattern. Interactive writes (a card take /
|
||||
* skip, a CRUD edit, an intake edit / delete / purge / import) pass
|
||||
* `{ evict: true }`: the user must see their own action on the very next
|
||||
* read, and a stale-serveable entry would hand back the pre-write body.
|
||||
* Background paths (the iOS bulk-intake sync, the auto-miss cron, slot
|
||||
* dedup) keep the default mark-stale so a high-frequency sync never busts
|
||||
* the buckets into inline cold-rebuild storms — the `cachedSwr` readers
|
||||
* serve the prior payload while one coalesced recompute warms each cell.
|
||||
*/
|
||||
export function invalidateUserMedications(userId: string): void {
|
||||
caches.medications.deleteByPrefix(userId);
|
||||
export function invalidateUserMedications(
|
||||
userId: string,
|
||||
opts?: { evict?: boolean },
|
||||
): void {
|
||||
if (opts?.evict) {
|
||||
caches.medications.deleteByPrefix(userId);
|
||||
// v1.15.20 — the per-medication compliance payload is cached 15 min;
|
||||
// an interactive intake / medication / schedule write must flush it
|
||||
// so the card rates and the heatmap reflect the action on the next
|
||||
// read.
|
||||
caches.medicationCompliance.deleteByPrefix(`${userId}|`);
|
||||
// v1.4.38 W-F — `/api/dashboard/summary` lives under the analytics
|
||||
// cache and reads `medicationIntakeEvent` for both today's compliance
|
||||
// tally and the 365-day streak feed. A taken / skipped event must
|
||||
// evict the user-bucket so the next iOS poll reflects the change.
|
||||
caches.analytics.deleteByPrefix(`${userId}|`);
|
||||
} else {
|
||||
// Mark-stale collapses the fresh TTL to "now": the SWR readers (the
|
||||
// medications list, both compliance routes, the dashboard snapshot)
|
||||
// serve the prior value while a single background recompute warms a
|
||||
// fresh one; the plain `cached` readers in the analytics bucket see
|
||||
// a clean miss — identical to the old evict for those keys.
|
||||
caches.medications.markStaleByPrefix(userId);
|
||||
caches.medicationCompliance.markStaleByPrefix(`${userId}|`);
|
||||
caches.analytics.markStaleByPrefix(`${userId}|`);
|
||||
}
|
||||
caches.medicationsIntake.deleteByPrefix(`${userId}|`);
|
||||
// v1.15.20 — the per-medication compliance payload is cached 15 min;
|
||||
// every intake / medication / schedule write must flush it so the card
|
||||
// rates and the heatmap reflect the action on the next read.
|
||||
caches.medicationCompliance.deleteByPrefix(`${userId}|`);
|
||||
caches.achievements.deleteByPrefix(userId);
|
||||
// v1.4.36 W1 — medication writes change the MEDICATION_COMPLIANCE
|
||||
// target rollup (compliance7 / compliance30 / consistency strip).
|
||||
caches.insightsTargets.deleteByPrefix(userId);
|
||||
// v1.4.38 W-F — `/api/dashboard/summary` lives under the analytics
|
||||
// cache and reads `medicationIntakeEvent` for both today's compliance
|
||||
// tally and the 365-day streak feed. A taken / skipped event must
|
||||
// evict the user-bucket so the next iOS poll reflects the change.
|
||||
caches.analytics.deleteByPrefix(`${userId}|`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Vendored
+22
@@ -363,10 +363,22 @@ export const caches = {
|
||||
ttlMs: 60_000,
|
||||
staleTtlMs: 600_000,
|
||||
}),
|
||||
/**
|
||||
* v1.16.8 — stale-while-revalidate on the list bucket. The 60 s fresh
|
||||
* TTL meant every visit minutes apart paid the full cold list build
|
||||
* (five parallel reads + the next-due engine per medication). The list
|
||||
* GET reads via `cachedSwr` now: inside the 10-minute stale window the
|
||||
* prior list serves immediately while one background recompute warms a
|
||||
* fresh one. Interactive writes hard-evict the bucket through
|
||||
* `invalidateUserMedications({ evict: true })`, so the window only
|
||||
* bounds wall-clock drift (`todayEventCount`, `nextDueAt`), never
|
||||
* user-action staleness.
|
||||
*/
|
||||
medications: new ServerCache<unknown>({
|
||||
name: "medications",
|
||||
maxEntries: 1000,
|
||||
ttlMs: 60_000,
|
||||
staleTtlMs: 600_000,
|
||||
}),
|
||||
achievements: new ServerCache<unknown>({
|
||||
name: "achievements",
|
||||
@@ -405,11 +417,21 @@ export const caches = {
|
||||
* `invalidateUserMedications`, so the 15-minute TTL only bounds
|
||||
* wall-clock drift (a dose flipping overdue), never user-action
|
||||
* staleness. Sized for a few hundred users × a handful of meds.
|
||||
*
|
||||
* v1.16.8 — stale-while-revalidate. Both compliance routes (the per-id
|
||||
* detail read and the batched card read) consume via `cachedSwr`:
|
||||
* inside the 10-minute stale window an expired cell serves the prior
|
||||
* payload immediately while one coalesced rebuild warms it. Interactive
|
||||
* intake / medication writes hard-evict (the user must see their own
|
||||
* dose on the next read); background sync paths (iOS bulk intake, the
|
||||
* auto-miss cron, slot dedup) mark stale so a high-frequency sync never
|
||||
* busts every card into an inline cold rebuild.
|
||||
*/
|
||||
medicationCompliance: new ServerCache<unknown>({
|
||||
name: "medicationCompliance",
|
||||
maxEntries: 2000,
|
||||
ttlMs: 900_000,
|
||||
staleTtlMs: 600_000,
|
||||
}),
|
||||
moodAnalytics: new ServerCache<unknown>({
|
||||
name: "moodAnalytics",
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* The full compliance computation for one medication, shared by
|
||||
* `GET /api/medications/[id]/compliance` (detail page, full payload incl.
|
||||
* the 90-day heatmap) and `GET /api/medications/compliance` (the batched
|
||||
* card read). Lifted out of the per-id route so the server cache can wrap
|
||||
* one canonical builder from both entry points: ONE bounded intake-event
|
||||
* read (only the columns the math consumes, floored at the 366-day fetch
|
||||
* window) feeds ONE shared band-expansion pass
|
||||
* (`buildMedicationComplianceBundle`) that serves the 7-/30-day blocks,
|
||||
* the cadence-scaled display rows AND the 90-day heatmap.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
buildComplianceMedicationContext,
|
||||
buildMedicationComplianceBundle,
|
||||
lastNonSkippedTakenAt,
|
||||
type ComplianceDisplay,
|
||||
type ComplianceResult,
|
||||
type DailyComplianceEntry,
|
||||
} from "@/lib/analytics/compliance";
|
||||
import type { DoseHistoryRow } from "@/lib/medications/scheduling/dose-history";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Lower bound for the intake-event read. The widest window any served
|
||||
* block needs is the 365-day display rung; one extra day absorbs the
|
||||
* inclusive-boundary edge so the ledger's own 365-day clamp never sits
|
||||
* on a half-fetched day.
|
||||
*/
|
||||
const EVENT_FETCH_WINDOW_DAYS = 366;
|
||||
|
||||
/** The cached response body (the exact public wire shape). */
|
||||
export interface CompliancePayload {
|
||||
compliance7: ComplianceResult;
|
||||
compliance30: ComplianceResult;
|
||||
dailyCompliance: Record<string, DailyComplianceEntry>;
|
||||
complianceDisplay: ComplianceDisplay;
|
||||
}
|
||||
|
||||
/** The medication slice the payload builder consumes. */
|
||||
export interface ComplianceMedicationInput {
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
startsOn: Date | null;
|
||||
endsOn: Date | null;
|
||||
oneShot: boolean;
|
||||
schedules: Parameters<typeof buildMedicationComplianceBundle>[1];
|
||||
/** v1.16.3 — archived schedule eras for era-aware compliance. */
|
||||
scheduleRevisions?: Parameters<
|
||||
typeof buildComplianceMedicationContext
|
||||
>[0]["scheduleRevisions"];
|
||||
}
|
||||
|
||||
/**
|
||||
* One canonical cache key per `(user, medication, timezone)` cell. The
|
||||
* key carries the timezone the payload was bucketed in: day keys, the
|
||||
* heatmap, and the overdue cut all derive from `userTz`, so a timezone
|
||||
* change must miss the old entry instead of serving day buckets computed
|
||||
* for the previous zone until the TTL lapses. The `${userId}|` prefix is
|
||||
* what `invalidateUserMedications` sweeps.
|
||||
*/
|
||||
export function complianceCacheKey(
|
||||
userId: string,
|
||||
medicationId: string,
|
||||
userTz: string,
|
||||
): string {
|
||||
return `${userId}|${medicationId}|compliance|${userTz}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.15.18 — fold one dose-history ledger row into a per-day heatmap entry.
|
||||
*
|
||||
* Slot rows contribute to `expected` / `expectedCount` (the day's due-slot
|
||||
* count): a taken slot adds to `taken` and its timing bucket; a missed slot
|
||||
* stays uncounted in `taken`; a skipped slot lands in `skipped`; an upcoming
|
||||
* slot is still due but not yet acted on. An ad-hoc row is a real off-schedule
|
||||
* take — it counts as `taken` AND adds its own `expected` slot (so the
|
||||
* heatmap's `missed = expected − taken − skipped` math stays non-negative) and
|
||||
* reads on-time (a logged dose colours green).
|
||||
*/
|
||||
function bucketLedgerRow(
|
||||
entry: DailyComplianceEntry,
|
||||
row: DoseHistoryRow,
|
||||
): void {
|
||||
switch (row.status) {
|
||||
case "taken_on_time":
|
||||
entry.expected++;
|
||||
entry.expectedCount++;
|
||||
entry.taken++;
|
||||
entry.onTime++;
|
||||
break;
|
||||
case "taken_late":
|
||||
entry.expected++;
|
||||
entry.expectedCount++;
|
||||
entry.taken++;
|
||||
entry.late++;
|
||||
break;
|
||||
case "missed":
|
||||
entry.expected++;
|
||||
entry.expectedCount++;
|
||||
break;
|
||||
case "skipped":
|
||||
entry.expected++;
|
||||
entry.expectedCount++;
|
||||
entry.skipped++;
|
||||
break;
|
||||
case "upcoming":
|
||||
// A future / still-takeable slot is due but not yet acted on. It counts
|
||||
// toward the day's expected/due grid but not toward taken or missed.
|
||||
entry.expected++;
|
||||
entry.expectedCount++;
|
||||
break;
|
||||
case "ad_hoc":
|
||||
// An off-schedule take: a real taken dose with no scheduled slot. Count
|
||||
// it as taken + its own expected slot so the heatmap missed math holds.
|
||||
entry.expected++;
|
||||
entry.expectedCount++;
|
||||
entry.taken++;
|
||||
entry.onTime++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildCompliancePayload(
|
||||
medication: ComplianceMedicationInput,
|
||||
userId: string,
|
||||
userTz: string,
|
||||
): Promise<CompliancePayload> {
|
||||
// v1.15.9 — pin a single `now` and thread it into every cadence
|
||||
// computation so no block can straddle a day boundary on a slow request.
|
||||
const now = new Date();
|
||||
const createdAt = medication.createdAt;
|
||||
|
||||
const fetchFrom = new Date(
|
||||
Math.max(
|
||||
createdAt.getTime(),
|
||||
now.getTime() - EVENT_FETCH_WINDOW_DAYS * DAY_MS,
|
||||
),
|
||||
);
|
||||
const events = await prisma.medicationIntakeEvent.findMany({
|
||||
// v1.7.0 sync — exclude tombstoned rows from the compliance read.
|
||||
// Bounded to the 366-day fetch window: every served block is a suffix
|
||||
// of the 365-day ledger window, so older rows can never change the
|
||||
// response.
|
||||
where: {
|
||||
medicationId: medication.id,
|
||||
userId,
|
||||
deletedAt: null,
|
||||
scheduledFor: { gte: fetchFrom },
|
||||
},
|
||||
orderBy: { scheduledFor: "desc" },
|
||||
select: {
|
||||
takenAt: true,
|
||||
skipped: true,
|
||||
scheduledFor: true,
|
||||
// v1.15.9 — a forgotten dose the auto-miss cron flipped counts as a
|
||||
// miss, not a neutral skip.
|
||||
autoMissed: true,
|
||||
// v1.15.20 — pinned takes bind by anchor in the unified ledger so the
|
||||
// % agrees with the history view on a "zugeordnet" dose (taken-late).
|
||||
attributionSource: true,
|
||||
},
|
||||
});
|
||||
|
||||
// v1.7.0 SB-SCHED-2 — thread the medication context so the denominator
|
||||
// routes through the canonical engine (RRULE / rolling / one-shot / PRN /
|
||||
// cyclic). `lastIntakeAt` is the latest non-skipped takenAt inside the
|
||||
// fetch window (rolling cadences re-anchor on it).
|
||||
const lastIntakeAt = lastNonSkippedTakenAt(events);
|
||||
const medicationContext = buildComplianceMedicationContext(
|
||||
medication,
|
||||
lastIntakeAt,
|
||||
userTz,
|
||||
);
|
||||
|
||||
// ONE shared expansion pass for every served block (the v1.15.20
|
||||
// performance fix): bands + ledger + timeline are minted once over the
|
||||
// widest window and each sub-window is a tally over the same rows.
|
||||
const bundle = buildMedicationComplianceBundle(
|
||||
events,
|
||||
medication.schedules,
|
||||
medicationContext,
|
||||
now,
|
||||
);
|
||||
|
||||
// v1.15.18 — the heatmap / line-chart daily map is bucketed from the ONE
|
||||
// unified dose-history ledger (the same bands the % + the history view
|
||||
// read), so the per-day timing split (on-time / late) and the per-day
|
||||
// missed marks can never disagree with the headline rate. The 90-day
|
||||
// window is carved out of the shared ledger by `row.at`; the window
|
||||
// floor clamps to the medication's creation so pre-existence days never
|
||||
// mint phantom slots.
|
||||
const heatmapFrom = new Date(
|
||||
Math.max(now.getTime() - 90 * DAY_MS, createdAt.getTime()),
|
||||
);
|
||||
|
||||
const dailyCompliance: Record<string, DailyComplianceEntry> = {};
|
||||
const byDay = new Map<string, DailyComplianceEntry>();
|
||||
const ensureDay = (key: string): DailyComplianceEntry => {
|
||||
let entry = byDay.get(key);
|
||||
if (!entry) {
|
||||
entry = {
|
||||
expected: 0,
|
||||
expectedCount: 0,
|
||||
due: false,
|
||||
taken: 0,
|
||||
skipped: 0,
|
||||
onTime: 0,
|
||||
late: 0,
|
||||
veryLate: 0,
|
||||
early: 0,
|
||||
};
|
||||
byDay.set(key, entry);
|
||||
}
|
||||
return entry;
|
||||
};
|
||||
|
||||
// Bucket each ledger row into its user-tz day. A slot row buckets on its
|
||||
// anchor; an ad-hoc row on its real take time.
|
||||
for (const row of bundle.ledgerRows) {
|
||||
if (row.at.getTime() < heatmapFrom.getTime()) continue;
|
||||
const key = userDayKey(row.at, userTz);
|
||||
const entry = ensureDay(key);
|
||||
bucketLedgerRow(entry, row);
|
||||
}
|
||||
|
||||
for (const [key, entry] of byDay) {
|
||||
entry.due = entry.expectedCount > 0;
|
||||
dailyCompliance[key] = entry;
|
||||
}
|
||||
|
||||
return {
|
||||
compliance7: bundle.compliance7,
|
||||
compliance30: bundle.compliance30,
|
||||
dailyCompliance,
|
||||
complianceDisplay: bundle.complianceDisplay,
|
||||
};
|
||||
}
|
||||
@@ -507,6 +507,19 @@ const medicationComplianceResponse = z
|
||||
"Adherence read for a single medication. `compliance30` is the authoritative 30-day taken-vs-expected summary; `dailyCompliance` is the per-day grid for the history glyph track. The graded raw→week→month→year series used elsewhere for AI prompts does NOT apply here — this response is never downsampled.",
|
||||
});
|
||||
|
||||
const medicationComplianceSummaryEntry = z
|
||||
.object({
|
||||
medicationId: z.string(),
|
||||
compliance7: complianceResult,
|
||||
compliance30: complianceResult,
|
||||
complianceDisplay,
|
||||
})
|
||||
.meta({
|
||||
id: "MedicationComplianceSummaryEntry",
|
||||
description:
|
||||
"Compact per-medication adherence row for the batched card read: the 7-/30-day summaries plus the cadence-scaled display block. The per-day `dailyCompliance` grid is NOT on this shape — read the per-medication `/compliance` endpoint for the history glyph track.",
|
||||
});
|
||||
|
||||
// v1.16.5 — schedule-era management (the Zeitplan-tab history timeline).
|
||||
// Archived eras come from two provenances: the wholesale-replace write
|
||||
// path (`ARCHIVED`, immutable) and the user-entered pre-tracking flow
|
||||
@@ -856,6 +869,28 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/medications/compliance": {
|
||||
get: {
|
||||
tags: ["Medications"],
|
||||
summary: "Batched adherence read for every medication of the caller",
|
||||
description:
|
||||
"Returns one compact adherence row per medication the caller owns (active + paused), ordered by `createdAt DESC` — the single round trip the medication cards consume instead of fanning out one `/api/medications/{id}/compliance` request per card. Each row carries the 7-/30-day summaries and the cadence-scaled display block; the per-day grid stays on the per-medication endpoint. Pure computation — no writes. Served through the same per-medication server cache as the per-id read, so the two endpoints warm each other.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "One adherence row per medication.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.array(medicationComplianceSummaryEntry),
|
||||
"ListMedicationComplianceResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/medications/{id}/compliance": {
|
||||
get: {
|
||||
tags: ["Medications"],
|
||||
|
||||
@@ -12,11 +12,20 @@ import { apiGet } from "@/lib/api/api-fetch";
|
||||
* puts `/api/medications` (the response carries the per-medication
|
||||
* `nextDueAt` due times) in flight while the navigation commits.
|
||||
*
|
||||
* v1.16.8 — the batched card-compliance read
|
||||
* (`/api/medications/compliance`) prefetches alongside the list, so the
|
||||
* compliance bars + the status line resolve in the same paint as the
|
||||
* cards instead of swapping in late. Two independent requests on
|
||||
* purpose: the list response must stay fast (the due line never waits
|
||||
* on a compliance ledger build), and the compliance prefetch rides the
|
||||
* same server-side cache cells the page query would warm anyway.
|
||||
*
|
||||
* The 15 s prefetch window only bounds how old a cache entry may be
|
||||
* before an intent re-fires the request — the mounted page query rides
|
||||
* the provider-default `staleTime` (5 min) and consumes whatever this
|
||||
* prefetch parked. The server caches the list 60 s per user, so even a
|
||||
* missed window stays cheap.
|
||||
* prefetch parked. The server caches both reads per user (60 s list /
|
||||
* 15 min compliance, each with an SWR window), so even a missed window
|
||||
* stays cheap.
|
||||
*/
|
||||
export const MEDICATIONS_LIST_STALE_TIME_MS = 15_000;
|
||||
|
||||
@@ -26,6 +35,11 @@ export function prefetchMedicationsList(queryClient: QueryClient): void {
|
||||
queryFn: () => apiGet("/api/medications"),
|
||||
staleTime: MEDICATIONS_LIST_STALE_TIME_MS,
|
||||
});
|
||||
void queryClient.prefetchQuery({
|
||||
queryKey: queryKeys.medicationComplianceSummary(),
|
||||
queryFn: () => apiGet("/api/medications/compliance"),
|
||||
staleTime: MEDICATIONS_LIST_STALE_TIME_MS,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { ComplianceDisplay } from "@/lib/analytics/compliance";
|
||||
|
||||
/**
|
||||
* One batched-summary row — the compact slice of the per-medication
|
||||
* compliance payload the cards render (rates, streak, the cadence-scaled
|
||||
* display block with `currentDose` / `currentCycle`). The heavy
|
||||
* `dailyCompliance` heatmap stays on the per-id endpoint the detail page
|
||||
* reads.
|
||||
*/
|
||||
export interface MedicationComplianceSummaryEntry {
|
||||
medicationId: string;
|
||||
compliance7: { rate: number; streak: number };
|
||||
compliance30: { rate: number };
|
||||
complianceDisplay?: ComplianceDisplay;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.16.8 — the per-card compliance read, batched.
|
||||
*
|
||||
* Every medication card used to fire its own
|
||||
* `GET /api/medications/{id}/compliance` (N+1 over the grid, ~1 s cold
|
||||
* each). All cards now share ONE `GET /api/medications/compliance`
|
||||
* round trip under a single query key; each card `select`s its own row,
|
||||
* so a grid of mounted cards collapses into one request and one cache
|
||||
* entry. The key rides the `["medications"]` prefix, so every intake /
|
||||
* CRUD mutation that invalidates `medicationDependentKeys` refreshes it.
|
||||
*
|
||||
* Lives in one shared hook (not per-card `useQuery` calls) so the two
|
||||
* card variants can never register the same key with diverging fetch
|
||||
* shapes.
|
||||
*/
|
||||
export function useMedicationComplianceSummary(medicationId: string): {
|
||||
data: MedicationComplianceSummaryEntry | null | undefined;
|
||||
} {
|
||||
const { data } = useQuery({
|
||||
queryKey: queryKeys.medicationComplianceSummary(),
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await apiGet<MedicationComplianceSummaryEntry[]>(
|
||||
"/api/medications/compliance",
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
// Dose actions invalidate the key explicitly through
|
||||
// `medicationDependentKeys`; a shorter window would only re-fire the
|
||||
// batch on every list visit. Matches the reminder-thresholds query
|
||||
// the cards mount alongside.
|
||||
staleTime: 5 * 60 * 1000,
|
||||
select: (rows: MedicationComplianceSummaryEntry[] | null) =>
|
||||
rows?.find((row) => row.medicationId === medicationId) ?? null,
|
||||
});
|
||||
return { data };
|
||||
}
|
||||
@@ -16,6 +16,18 @@ export const medicationKeys = {
|
||||
*/
|
||||
medicationCompliance: (medicationId: string) =>
|
||||
["medications", medicationId, "compliance"] as const,
|
||||
/**
|
||||
* v1.16.8 — the batched card-compliance read
|
||||
* (`GET /api/medications/compliance`, one round trip for every card on
|
||||
* the medications page). Replaces the per-card fan-out over the per-id
|
||||
* endpoint; `medicationCompliance(id)` above stays for the detail page,
|
||||
* which needs the per-id payload's heatmap grid. Rides under the
|
||||
* `["medications"]` prefix so every intake / CRUD mutation reaches it
|
||||
* through `medicationDependentKeys`. The static second segment cannot
|
||||
* collide with `medicationDetail(id)` — ids are cuids.
|
||||
*/
|
||||
medicationComplianceSummary: () =>
|
||||
["medications", "compliance-summary"] as const,
|
||||
medicationCadence: (medicationId: string) =>
|
||||
["medications", medicationId, "cadence"] as const,
|
||||
medicationGlp1Details: (medicationId: string) =>
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* v1.16.8 — memoised `Intl.DateTimeFormat` construction.
|
||||
*
|
||||
* Constructing a formatter dominates the CPU cost of the wall-clock
|
||||
* helpers (band expansion calls them in a tight loop), so the memo must
|
||||
* (a) return byte-identical output to a freshly constructed formatter
|
||||
* for every timezone / option shape the tz helpers use, and (b) actually
|
||||
* share one instance per signature.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
|
||||
import {
|
||||
getDateTimeFormat,
|
||||
__resetIntlCacheForTests,
|
||||
__intlCacheSizeForTests,
|
||||
} from "../intl-cache";
|
||||
import { wallClockInTz } from "../wall-clock";
|
||||
import { formatInUserTz, userDayKey, isValidTimezone } from "../format";
|
||||
import { getUserTodayBounds, localHmAsUtc } from "../local-day";
|
||||
|
||||
const ZONES = [
|
||||
"UTC",
|
||||
"Europe/Berlin",
|
||||
"America/New_York",
|
||||
"Asia/Tokyo",
|
||||
"Pacific/Kiritimati", // +14, no DST
|
||||
"America/St_Johns", // -03:30 fractional offset
|
||||
];
|
||||
|
||||
// Instants chosen to straddle DST transitions and a midnight boundary.
|
||||
const INSTANTS = [
|
||||
new Date("2026-01-15T03:30:00Z"),
|
||||
new Date("2026-03-29T01:30:00Z"), // EU spring-forward window
|
||||
new Date("2026-06-10T23:45:00Z"),
|
||||
new Date("2026-10-25T01:30:00Z"), // EU fall-back window
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
__resetIntlCacheForTests();
|
||||
});
|
||||
|
||||
describe("getDateTimeFormat", () => {
|
||||
it("matches a freshly constructed formatter across zones and option shapes", () => {
|
||||
const optionShapes: Omit<Intl.DateTimeFormatOptions, "timeZone">[] = [
|
||||
{
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
weekday: "short",
|
||||
},
|
||||
{
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
},
|
||||
{ hour: "2-digit", minute: "2-digit", hour12: false },
|
||||
];
|
||||
for (const tz of ZONES) {
|
||||
for (const options of optionShapes) {
|
||||
const memoised = getDateTimeFormat("en-CA", tz, options);
|
||||
const fresh = new Intl.DateTimeFormat("en-CA", {
|
||||
...options,
|
||||
timeZone: tz,
|
||||
});
|
||||
for (const instant of INSTANTS) {
|
||||
expect(memoised.format(instant)).toBe(fresh.format(instant));
|
||||
expect(memoised.formatToParts(instant)).toEqual(
|
||||
fresh.formatToParts(instant),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("returns the same instance for the same (locale, timeZone, options) signature", () => {
|
||||
const options = {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
} as const;
|
||||
const a = getDateTimeFormat("en-GB", "Europe/Berlin", options);
|
||||
const b = getDateTimeFormat("en-GB", "Europe/Berlin", options);
|
||||
expect(a).toBe(b);
|
||||
// A structurally equal but distinct options object still hits the
|
||||
// same cache cell (signature-keyed, not identity-keyed).
|
||||
const c = getDateTimeFormat("en-GB", "Europe/Berlin", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
} as const);
|
||||
expect(c).toBe(a);
|
||||
});
|
||||
|
||||
it("splits cells on locale, timezone, and options", () => {
|
||||
const options = {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
} as const;
|
||||
const base = getDateTimeFormat("en-GB", "Europe/Berlin", options);
|
||||
expect(getDateTimeFormat("en-US", "Europe/Berlin", options)).not.toBe(base);
|
||||
expect(getDateTimeFormat("en-GB", "Asia/Tokyo", options)).not.toBe(base);
|
||||
expect(
|
||||
getDateTimeFormat("en-GB", "Europe/Berlin", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
} as const),
|
||||
).not.toBe(base);
|
||||
expect(__intlCacheSizeForTests()).toBe(4);
|
||||
});
|
||||
|
||||
it("throws on an invalid timezone without caching the failure", () => {
|
||||
expect(() => getDateTimeFormat("en-US", "Not/AZone", {})).toThrow();
|
||||
expect(__intlCacheSizeForTests()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("memoised consumers stay output-identical to the pre-memo helpers", () => {
|
||||
it("wallClockInTz decomposes DST-transition instants correctly", () => {
|
||||
// 2026-03-29 01:30 UTC = 03:30 CEST (the EU spring-forward morning).
|
||||
const parts = wallClockInTz(new Date("2026-03-29T01:30:00Z"), "Europe/Berlin");
|
||||
expect(parts).toEqual({
|
||||
year: 2026,
|
||||
month: 3,
|
||||
day: 29,
|
||||
hour: 3,
|
||||
minute: 30,
|
||||
second: 0,
|
||||
weekday: 0,
|
||||
});
|
||||
// Same instant read from Tokyo.
|
||||
const tokyo = wallClockInTz(new Date("2026-03-29T01:30:00Z"), "Asia/Tokyo");
|
||||
expect(tokyo).toEqual({
|
||||
year: 2026,
|
||||
month: 3,
|
||||
day: 29,
|
||||
hour: 10,
|
||||
minute: 30,
|
||||
second: 0,
|
||||
weekday: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("formatInUserTz keeps every shape stable across repeat (memo-warm) calls", () => {
|
||||
const instant = new Date("2026-06-10T23:45:00Z");
|
||||
for (let i = 0; i < 2; i++) {
|
||||
expect(formatInUserTz(instant, "Europe/Berlin", "iso-with-offset")).toBe(
|
||||
"2026-06-11T01:45:00+02:00",
|
||||
);
|
||||
expect(formatInUserTz(instant, "Europe/Berlin", "wall-clock")).toBe(
|
||||
"01:45",
|
||||
);
|
||||
expect(formatInUserTz(instant, "Europe/Berlin", "date")).toBe(
|
||||
"2026-06-11",
|
||||
);
|
||||
expect(userDayKey(instant, "America/New_York")).toBe("2026-06-10");
|
||||
}
|
||||
});
|
||||
|
||||
it("getUserTodayBounds + localHmAsUtc agree with the wall-clock read", () => {
|
||||
const now = new Date("2026-06-10T23:45:00Z"); // 01:45 on the 11th in Berlin
|
||||
const { start, end } = getUserTodayBounds(now, "Europe/Berlin");
|
||||
expect(start.toISOString()).toBe("2026-06-10T22:00:00.000Z");
|
||||
expect(end.toISOString()).toBe("2026-06-11T21:59:59.999Z");
|
||||
const eight = localHmAsUtc(now, "Europe/Berlin", 8, 0);
|
||||
expect(eight.toISOString()).toBe("2026-06-11T06:00:00.000Z");
|
||||
});
|
||||
|
||||
it("isValidTimezone accepts real zones and rejects junk through the memo", () => {
|
||||
expect(isValidTimezone("Europe/Berlin")).toBe(true);
|
||||
expect(isValidTimezone("Pacific/Kiritimati")).toBe(true);
|
||||
expect(isValidTimezone("Not/AZone")).toBe(false);
|
||||
expect(isValidTimezone("")).toBe(false);
|
||||
});
|
||||
});
|
||||
+36
-27
@@ -12,8 +12,35 @@
|
||||
* lookup.
|
||||
*/
|
||||
|
||||
import { getDateTimeFormat } from "./intl-cache";
|
||||
|
||||
export const DEFAULT_TIMEZONE = "Europe/Berlin";
|
||||
|
||||
/**
|
||||
* Hoisted option constants so the formatter memo's WeakMap signature
|
||||
* lookup hits the same object identity on every call — see
|
||||
* `./intl-cache`. `WALL_CLOCK_PARTS_OPTIONS` is shared by
|
||||
* `wallClockParts` AND `tzOffsetMinutes`, so both resolve to the SAME
|
||||
* memoised formatter per timezone.
|
||||
*/
|
||||
const VALIDATION_OPTIONS: Omit<Intl.DateTimeFormatOptions, "timeZone"> = {};
|
||||
|
||||
const HOUR_MINUTE_OPTIONS: Omit<Intl.DateTimeFormatOptions, "timeZone"> = {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
};
|
||||
|
||||
const WALL_CLOCK_PARTS_OPTIONS: Omit<Intl.DateTimeFormatOptions, "timeZone"> = {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate a timezone string by asking `Intl.DateTimeFormat` to use it.
|
||||
* Returns `true` if the runtime accepts the zone, `false` otherwise.
|
||||
@@ -24,7 +51,10 @@ export function isValidTimezone(tz: string): boolean {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
new Intl.DateTimeFormat("en-US", { timeZone: tz });
|
||||
// The memo only caches successful constructions, so probing an
|
||||
// invalid zone through it cannot poison the formatter map — and a
|
||||
// valid zone leaves its formatter warm for the real callers.
|
||||
getDateTimeFormat("en-US", tz, VALIDATION_OPTIONS);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -133,12 +163,9 @@ export function formatInUserTz(
|
||||
case "iso-with-offset":
|
||||
return formatIsoWithOffset(date, safeTz);
|
||||
case "wall-clock":
|
||||
return new Intl.DateTimeFormat("en-GB", {
|
||||
timeZone: safeTz,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}).format(date);
|
||||
return getDateTimeFormat("en-GB", safeTz, HOUR_MINUTE_OPTIONS).format(
|
||||
date,
|
||||
);
|
||||
case "datetime": {
|
||||
const parts = wallClockParts(date, safeTz);
|
||||
return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}`;
|
||||
@@ -171,16 +198,7 @@ interface WallClockParts {
|
||||
}
|
||||
|
||||
function wallClockParts(date: Date, tz: string): WallClockParts {
|
||||
const fmt = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: tz,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
const fmt = getDateTimeFormat("en-CA", tz, WALL_CLOCK_PARTS_OPTIONS);
|
||||
const parts = fmt.formatToParts(date);
|
||||
const get = (type: Intl.DateTimeFormatPartTypes): string =>
|
||||
parts.find((p) => p.type === type)?.value ?? "00";
|
||||
@@ -223,16 +241,7 @@ function formatIsoWithOffset(date: Date, tz: string): string {
|
||||
* difference between the wall-clock parts and the UTC parts.
|
||||
*/
|
||||
function tzOffsetMinutes(date: Date, tz: string): number {
|
||||
const fmt = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: tz,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
const fmt = getDateTimeFormat("en-CA", tz, WALL_CLOCK_PARTS_OPTIONS);
|
||||
const parts = fmt.formatToParts(date);
|
||||
const get = (type: Intl.DateTimeFormatPartTypes): number =>
|
||||
Number(parts.find((p) => p.type === type)?.value ?? "0");
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Module-level memo for `Intl.DateTimeFormat` construction.
|
||||
*
|
||||
* Constructing a formatter resolves locale + timezone data on every call
|
||||
* and dominates the CPU cost of the wall-clock helpers (~10x the
|
||||
* `format` / `formatToParts` call it feeds). The instances are stateless
|
||||
* for `format` / `formatToParts`, so one shared formatter per
|
||||
* `(locale, timeZone, options)` signature is safe to hand to every
|
||||
* caller — compliance band expansion, rollups, the dose-history ledger
|
||||
* and the export formatters all hit the same small set of signatures.
|
||||
*
|
||||
* `timeZone` is a first-class key segment (not folded into `options`) so
|
||||
* call sites can hoist their options object to a module constant; a
|
||||
* WeakMap memoises that constant's serialised signature, leaving a warm
|
||||
* lookup as two string concats + one Map get.
|
||||
*/
|
||||
|
||||
const formatterCache = new Map<string, Intl.DateTimeFormat>();
|
||||
|
||||
/**
|
||||
* Hard cap. The realistic population is bounded (valid IANA zones × a
|
||||
* handful of option shapes), but the timezone segment is user-supplied
|
||||
* upstream of validation in `isValidTimezone`, so the memo carries a
|
||||
* safety valve: on overflow the whole map resets and re-warms.
|
||||
*/
|
||||
const MAX_FORMATTERS = 1000;
|
||||
|
||||
const signatureByOptions = new WeakMap<
|
||||
Omit<Intl.DateTimeFormatOptions, "timeZone">,
|
||||
string
|
||||
>();
|
||||
|
||||
function optionsSignature(
|
||||
options: Omit<Intl.DateTimeFormatOptions, "timeZone">,
|
||||
): string {
|
||||
const memoised = signatureByOptions.get(options);
|
||||
if (memoised !== undefined) return memoised;
|
||||
const record = options as Record<string, unknown>;
|
||||
const signature = Object.keys(record)
|
||||
.sort()
|
||||
.map((key) => `${key}=${String(record[key])}`)
|
||||
.join(",");
|
||||
signatureByOptions.set(options, signature);
|
||||
return signature;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared, memoised `Intl.DateTimeFormat`. Throws (like the constructor)
|
||||
* on an invalid `timeZone` — failures are never cached, so
|
||||
* `isValidTimezone` can probe through this without poisoning the map.
|
||||
*/
|
||||
export function getDateTimeFormat(
|
||||
locale: string,
|
||||
timeZone: string | undefined,
|
||||
options: Omit<Intl.DateTimeFormatOptions, "timeZone"> = {},
|
||||
): Intl.DateTimeFormat {
|
||||
const key = `${locale}|${timeZone ?? ""}|${optionsSignature(options)}`;
|
||||
const hit = formatterCache.get(key);
|
||||
if (hit) return hit;
|
||||
const formatter = new Intl.DateTimeFormat(
|
||||
locale,
|
||||
timeZone === undefined ? options : { ...options, timeZone },
|
||||
);
|
||||
if (formatterCache.size >= MAX_FORMATTERS) formatterCache.clear();
|
||||
formatterCache.set(key, formatter);
|
||||
return formatter;
|
||||
}
|
||||
|
||||
/** Test-only escape hatch — clear the memo between assertions. */
|
||||
export function __resetIntlCacheForTests(): void {
|
||||
formatterCache.clear();
|
||||
}
|
||||
|
||||
/** Test-only introspection — current memo population. */
|
||||
export function __intlCacheSizeForTests(): number {
|
||||
return formatterCache.size;
|
||||
}
|
||||
+22
-11
@@ -18,6 +18,8 @@
|
||||
* staying in lock-step; keeping them as separate definitions made
|
||||
* silent drift a real failure mode.
|
||||
*/
|
||||
import { getDateTimeFormat } from "./intl-cache";
|
||||
|
||||
export interface WallClockParts {
|
||||
year: number;
|
||||
/** 1–12 (January = 1, December = 12). */
|
||||
@@ -42,6 +44,23 @@ const WEEKDAY_MAP: Record<string, number> = {
|
||||
Sat: 6,
|
||||
};
|
||||
|
||||
/**
|
||||
* Hoisted so the formatter memo's WeakMap signature lookup hits the same
|
||||
* object identity on every call — a warm `wallClockInTz` no longer pays
|
||||
* `Intl.DateTimeFormat` construction (the dominant cost of the band
|
||||
* expansion + rollup paths that call this in a tight loop).
|
||||
*/
|
||||
const WALL_CLOCK_OPTIONS: Omit<Intl.DateTimeFormatOptions, "timeZone"> = {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
weekday: "short",
|
||||
};
|
||||
|
||||
export function wallClockInTz(
|
||||
date: Date,
|
||||
tz: string | undefined,
|
||||
@@ -57,17 +76,9 @@ export function wallClockInTz(
|
||||
weekday: date.getDay(),
|
||||
};
|
||||
}
|
||||
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,
|
||||
weekday: "short",
|
||||
}).formatToParts(date);
|
||||
const parts = getDateTimeFormat("en-US", tz, WALL_CLOCK_OPTIONS).formatToParts(
|
||||
date,
|
||||
);
|
||||
const get = (type: Intl.DateTimeFormatPartTypes): string =>
|
||||
parts.find((p) => p.type === type)?.value ?? "0";
|
||||
let hour = Number(get("hour"));
|
||||
|
||||
Reference in new issue
Block a user