mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
perf: collapse the medication compliance hot path and the dashboard fan-out
The medication list fired one compliance request per card every 30 seconds, and each request scanned the medication's full intake history and expanded the recurrence calendar five to six times. The endpoint now mints bands once per request, serves the sub-windows from that single ledger, bounds the event read to a year of selected columns and sits behind a write-invalidated 15-minute server cache; the cards poll at five minutes. The dashboard drops two redundant checklist queries by reading counts from the snapshot, the BMI chart derives from the weight series instead of refetching it, the detail page no longer serialises behind the auth probe and seeds its query from the list row. Expensive analytics reads gain a shared per-user rate limit, and the service worker stops serving cross-version shells, enables navigation preload and trims caches in one pass.
This commit is contained in:
1 parent
41ba4c6c17
commit
298e4c34b4
24 files changed
+1483
-319
No files matched your search
+49
-21
@@ -50,16 +50,24 @@ self.addEventListener("install", (event) => {
|
||||
// ── Activate: clean up old caches ────────────────────────────────────────────
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.keys()
|
||||
.then((keys) =>
|
||||
Promise.all(
|
||||
keys
|
||||
.filter((k) => k !== STATIC_CACHE && k !== PAGE_CACHE)
|
||||
.map((k) => caches.delete(k)),
|
||||
Promise.all([
|
||||
caches
|
||||
.keys()
|
||||
.then((keys) =>
|
||||
Promise.all(
|
||||
keys
|
||||
.filter((k) => k !== STATIC_CACHE && k !== PAGE_CACHE)
|
||||
.map((k) => caches.delete(k)),
|
||||
),
|
||||
),
|
||||
)
|
||||
.then(() => self.clients.claim()),
|
||||
// Navigation preload lets the browser start the navigation request
|
||||
// in parallel with service-worker startup; `networkFirst` consumes
|
||||
// it via `event.preloadResponse`, shaving SW boot latency off every
|
||||
// page navigation. Guarded — older engines lack the API.
|
||||
self.registration.navigationPreload
|
||||
? self.registration.navigationPreload.enable()
|
||||
: Promise.resolve(),
|
||||
]).then(() => self.clients.claim()),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -86,9 +94,10 @@ self.addEventListener("fetch", (event) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// HTML pages — network-first with cache fallback
|
||||
// HTML pages — network-first with cache fallback. The whole event is
|
||||
// passed so `networkFirst` can consume the navigation-preload response.
|
||||
if (request.headers.get("accept")?.includes("text/html")) {
|
||||
event.respondWith(networkFirst(request, PAGE_CACHE));
|
||||
event.respondWith(networkFirst(event, PAGE_CACHE));
|
||||
return;
|
||||
}
|
||||
});
|
||||
@@ -110,11 +119,21 @@ async function cacheFirst(request, cacheName) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Network-first: try network, fall back to cache.
|
||||
* Network-first: try network (preferring the navigation-preload response
|
||||
* when the browser already started it), fall back to cache.
|
||||
*
|
||||
* The offline fallback only serves a shell cached under the CURRENT
|
||||
* `CACHE_VERSION` cache names. The previous global `caches.match()` could
|
||||
* resolve from a stale pre-update cache in the activation gap and serve a
|
||||
* shell whose `/_next/static/*` chunk graph no longer exists
|
||||
* (`ChunkLoadError` on the first lazy navigation).
|
||||
*/
|
||||
async function networkFirst(request, cacheName) {
|
||||
async function networkFirst(event, cacheName) {
|
||||
const { request } = event;
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
const response =
|
||||
(event.preloadResponse ? await event.preloadResponse : null) ||
|
||||
(await fetch(request));
|
||||
if (response.ok) {
|
||||
const cache = await caches.open(cacheName);
|
||||
cache.put(request, response.clone());
|
||||
@@ -122,8 +141,15 @@ async function networkFirst(request, cacheName) {
|
||||
}
|
||||
return response;
|
||||
} catch {
|
||||
const cached = await caches.match(request);
|
||||
if (cached) return cached;
|
||||
// Version-scoped fallback: the page cache first, then the precached
|
||||
// app shell — both names embed CACHE_VERSION, so a stale shell from a
|
||||
// previous release can never be served here.
|
||||
const pageCache = await caches.open(cacheName);
|
||||
const cachedPage = await pageCache.match(request);
|
||||
if (cachedPage) return cachedPage;
|
||||
const staticCache = await caches.open(STATIC_CACHE);
|
||||
const precached = await staticCache.match(request);
|
||||
if (precached) return precached;
|
||||
|
||||
// Offline fallback. F-7 (mobile security audit, 2026-05-16): the
|
||||
// previous fallback rendered hard-coded German body copy, which is
|
||||
@@ -143,14 +169,16 @@ async function networkFirst(request, cacheName) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Trim cache to maxEntries by removing oldest entries.
|
||||
* Trim cache to maxEntries by removing oldest entries. `keys()` returns
|
||||
* insertion order, so one read + a single pass over the excess prefix
|
||||
* replaces the previous re-fetch-keys-per-deletion loop (O(n²) reads).
|
||||
*/
|
||||
async function trimCache(cacheName, maxEntries) {
|
||||
const cache = await caches.open(cacheName);
|
||||
let keys = await cache.keys();
|
||||
while (keys.length > maxEntries) {
|
||||
await cache.delete(keys[0]);
|
||||
keys = await cache.keys();
|
||||
const keys = await cache.keys();
|
||||
const excess = keys.length - maxEntries;
|
||||
for (let i = 0; i < excess; i++) {
|
||||
await cache.delete(keys[i]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* v1.15.20 — service-worker cache behaviour, evaluated in a `vm` sandbox.
|
||||
*
|
||||
* `public/sw.js` is plain script (no module system), so the test boots it
|
||||
* inside a vm context with a fake CacheStorage and dispatches the captured
|
||||
* event listeners. Three behaviours are pinned:
|
||||
*
|
||||
* 1. the offline HTML fallback only serves a shell cached under the
|
||||
* CURRENT `CACHE_VERSION` cache names (a stale pre-update shell would
|
||||
* reference a chunk graph that no longer exists);
|
||||
* 2. `activate` enables navigation preload and `networkFirst` consumes
|
||||
* `event.preloadResponse` when the browser supplies it;
|
||||
* 3. `trimCache` reads the key list once and deletes the excess prefix
|
||||
* in a single pass (the previous loop re-fetched all keys per delete).
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import vm from "node:vm";
|
||||
|
||||
const SW_SOURCE = readFileSync(
|
||||
resolve(__dirname, "../../public/sw.js"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const ORIGIN = "https://app.example";
|
||||
// `importScripts` throws in the sandbox, so the literal fallback version
|
||||
// is active and the cache names are deterministic.
|
||||
const CURRENT_PAGE_CACHE = "healthlog-pages-v1.4.43";
|
||||
const CURRENT_STATIC_CACHE = "healthlog-static-v1.4.43";
|
||||
|
||||
class FakeCache {
|
||||
map = new Map<string, Response>();
|
||||
keysCalls = 0;
|
||||
|
||||
private keyOf(request: RequestInfo | URL): string {
|
||||
if (typeof request === "string") return new URL(request, ORIGIN).href;
|
||||
if (request instanceof URL) return request.href;
|
||||
return request.url;
|
||||
}
|
||||
|
||||
async match(request: RequestInfo | URL): Promise<Response | undefined> {
|
||||
return this.map.get(this.keyOf(request));
|
||||
}
|
||||
|
||||
async put(request: RequestInfo | URL, response: Response): Promise<void> {
|
||||
this.map.set(this.keyOf(request), response);
|
||||
}
|
||||
|
||||
async addAll(urls: string[]): Promise<void> {
|
||||
for (const url of urls) {
|
||||
this.map.set(this.keyOf(url), new Response(`precached:${url}`));
|
||||
}
|
||||
}
|
||||
|
||||
async keys(): Promise<Request[]> {
|
||||
this.keysCalls += 1;
|
||||
return [...this.map.keys()].map((url) => new Request(url));
|
||||
}
|
||||
|
||||
async delete(request: RequestInfo | URL): Promise<boolean> {
|
||||
return this.map.delete(this.keyOf(request));
|
||||
}
|
||||
}
|
||||
|
||||
class FakeCacheStorage {
|
||||
stores = new Map<string, FakeCache>();
|
||||
|
||||
async open(name: string): Promise<FakeCache> {
|
||||
let store = this.stores.get(name);
|
||||
if (!store) {
|
||||
store = new FakeCache();
|
||||
this.stores.set(name, store);
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
async keys(): Promise<string[]> {
|
||||
return [...this.stores.keys()];
|
||||
}
|
||||
|
||||
async delete(name: string): Promise<boolean> {
|
||||
return this.stores.delete(name);
|
||||
}
|
||||
|
||||
async match(request: RequestInfo | URL): Promise<Response | undefined> {
|
||||
for (const store of this.stores.values()) {
|
||||
const hit = await store.match(request);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
interface SwHarness {
|
||||
listeners: Map<string, (event: unknown) => void>;
|
||||
cacheStorage: FakeCacheStorage;
|
||||
navPreload: { enabled: boolean; enable: () => Promise<void> };
|
||||
context: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function bootServiceWorker(): SwHarness {
|
||||
const listeners = new Map<string, (event: unknown) => void>();
|
||||
const cacheStorage = new FakeCacheStorage();
|
||||
const navPreload = {
|
||||
enabled: false,
|
||||
enable: async () => {
|
||||
navPreload.enabled = true;
|
||||
},
|
||||
};
|
||||
const selfObj = {
|
||||
addEventListener: (type: string, fn: (event: unknown) => void) => {
|
||||
listeners.set(type, fn);
|
||||
},
|
||||
skipWaiting: async () => {},
|
||||
clients: { claim: async () => {} },
|
||||
registration: { navigationPreload: navPreload },
|
||||
location: { origin: ORIGIN },
|
||||
};
|
||||
const context: Record<string, unknown> = {
|
||||
self: selfObj,
|
||||
caches: cacheStorage,
|
||||
importScripts: () => {
|
||||
throw new Error("no generated version file in the sandbox");
|
||||
},
|
||||
// Default: network down. Individual tests override `context.fetch`.
|
||||
fetch: async () => {
|
||||
throw new TypeError("network down");
|
||||
},
|
||||
Response,
|
||||
Request,
|
||||
URL,
|
||||
Promise,
|
||||
console,
|
||||
};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(SW_SOURCE, context);
|
||||
return { listeners, cacheStorage, navPreload, context };
|
||||
}
|
||||
|
||||
async function dispatchActivate(harness: SwHarness): Promise<void> {
|
||||
let settled: Promise<unknown> = Promise.resolve();
|
||||
harness.listeners.get("activate")!({
|
||||
waitUntil: (p: Promise<unknown>) => {
|
||||
settled = p;
|
||||
},
|
||||
});
|
||||
await settled;
|
||||
}
|
||||
|
||||
function dispatchNavigationFetch(
|
||||
harness: SwHarness,
|
||||
path: string,
|
||||
preloadResponse?: Promise<Response | undefined>,
|
||||
): Promise<Response> {
|
||||
let captured: Promise<Response> | null = null;
|
||||
harness.listeners.get("fetch")!({
|
||||
request: new Request(`${ORIGIN}${path}`, {
|
||||
headers: { accept: "text/html" },
|
||||
}),
|
||||
respondWith: (p: Promise<Response>) => {
|
||||
captured = p;
|
||||
},
|
||||
preloadResponse,
|
||||
});
|
||||
if (!captured) throw new Error("fetch handler did not respond");
|
||||
return captured;
|
||||
}
|
||||
|
||||
describe("sw.js — activate", () => {
|
||||
it("drops stale-version caches and enables navigation preload", async () => {
|
||||
const harness = bootServiceWorker();
|
||||
const stale = await harness.cacheStorage.open("healthlog-pages-v0.0.1");
|
||||
await stale.put(`${ORIGIN}/`, new Response("stale shell"));
|
||||
await harness.cacheStorage.open(CURRENT_PAGE_CACHE);
|
||||
|
||||
await dispatchActivate(harness);
|
||||
|
||||
expect(harness.cacheStorage.stores.has("healthlog-pages-v0.0.1")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(harness.cacheStorage.stores.has(CURRENT_PAGE_CACHE)).toBe(true);
|
||||
expect(harness.navPreload.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sw.js — networkFirst offline fallback", () => {
|
||||
it("never serves a shell cached under a previous CACHE_VERSION", async () => {
|
||||
const harness = bootServiceWorker();
|
||||
// A stale pre-update cache that survived into the activation gap.
|
||||
const stale = await harness.cacheStorage.open("healthlog-pages-v0.0.1");
|
||||
await stale.put(`${ORIGIN}/`, new Response("stale shell"));
|
||||
|
||||
const res = await dispatchNavigationFetch(harness, "/");
|
||||
|
||||
// The stale shell is ignored; the language-neutral offline page wins.
|
||||
expect(res.status).toBe(503);
|
||||
expect(await res.text()).toContain("Offline");
|
||||
});
|
||||
|
||||
it("serves the current-version page cache when offline", async () => {
|
||||
const harness = bootServiceWorker();
|
||||
const pages = await harness.cacheStorage.open(CURRENT_PAGE_CACHE);
|
||||
await pages.put(`${ORIGIN}/`, new Response("current shell"));
|
||||
|
||||
const res = await dispatchNavigationFetch(harness, "/");
|
||||
expect(await res.text()).toBe("current shell");
|
||||
});
|
||||
|
||||
it("falls back to the current-version precached shell when the page cache misses", async () => {
|
||||
const harness = bootServiceWorker();
|
||||
const statics = await harness.cacheStorage.open(CURRENT_STATIC_CACHE);
|
||||
await statics.addAll(["/"]);
|
||||
|
||||
const res = await dispatchNavigationFetch(harness, "/");
|
||||
expect(await res.text()).toBe("precached:/");
|
||||
});
|
||||
|
||||
it("consumes the navigation-preload response instead of re-fetching", async () => {
|
||||
const harness = bootServiceWorker();
|
||||
let fetchCalls = 0;
|
||||
harness.context.fetch = async () => {
|
||||
fetchCalls += 1;
|
||||
return new Response("network shell");
|
||||
};
|
||||
|
||||
const res = await dispatchNavigationFetch(
|
||||
harness,
|
||||
"/",
|
||||
Promise.resolve(new Response("preloaded shell")),
|
||||
);
|
||||
|
||||
expect(await res.clone().text()).toBe("preloaded shell");
|
||||
expect(fetchCalls).toBe(0);
|
||||
// The preloaded response was cached under the current page cache.
|
||||
const pages = await harness.cacheStorage.open(CURRENT_PAGE_CACHE);
|
||||
expect(await pages.match(`${ORIGIN}/`)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sw.js — trimCache", () => {
|
||||
it("reads the key list once and deletes only the oldest excess entries", async () => {
|
||||
const harness = bootServiceWorker();
|
||||
const store = await harness.cacheStorage.open(CURRENT_PAGE_CACHE);
|
||||
for (let i = 0; i < 8; i++) {
|
||||
await store.put(`${ORIGIN}/page-${i}`, new Response(`p${i}`));
|
||||
}
|
||||
store.keysCalls = 0;
|
||||
|
||||
const trimCache = (
|
||||
harness.context as { trimCache?: (name: string, max: number) => Promise<void> }
|
||||
).trimCache;
|
||||
expect(typeof trimCache).toBe("function");
|
||||
await trimCache!(CURRENT_PAGE_CACHE, 5);
|
||||
|
||||
// Single key read (the previous implementation re-read per deletion).
|
||||
expect(store.keysCalls).toBe(1);
|
||||
// Oldest three gone, newest five kept.
|
||||
expect(store.map.has(`${ORIGIN}/page-0`)).toBe(false);
|
||||
expect(store.map.has(`${ORIGIN}/page-2`)).toBe(false);
|
||||
expect(store.map.has(`${ORIGIN}/page-3`)).toBe(true);
|
||||
expect(store.map.has(`${ORIGIN}/page-7`)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -20,8 +20,9 @@ import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
import { measurementTypeEnum } from "@/lib/validations/measurement";
|
||||
import {
|
||||
ANALYTICS_RANGES,
|
||||
@@ -40,6 +41,12 @@ const rangeQuerySchema = z.object({
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// v1.15.20 — shared analytics-read budget (generous; caps runaway loops).
|
||||
const rl = await checkAnalyticsReadRateLimit(user.id);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many analytics requests. Please retry later.", 429);
|
||||
}
|
||||
|
||||
const parsed = rangeQuerySchema.safeParse({
|
||||
type: request.nextUrl.searchParams.get("type"),
|
||||
range: request.nextUrl.searchParams.get("range"),
|
||||
|
||||
@@ -50,6 +50,12 @@ vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// v1.15.20 — the route checks the shared analytics-read budget before any
|
||||
// DB work; the real helper would hit the unmocked `$queryRaw`.
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkAnalyticsReadRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
@@ -84,6 +90,7 @@ import { GET } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
|
||||
|
||||
const SESSION_OK = {
|
||||
@@ -104,6 +111,12 @@ function makeReq(): NextRequest {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
__resetAllCachesForTests();
|
||||
// v1.15.20 — default to an allowing analytics-read budget.
|
||||
vi.mocked(checkAnalyticsReadRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 119,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
// Default to assistant-on so the gate doesn't 403 every test.
|
||||
(
|
||||
prisma.appSettings.findUnique as ReturnType<typeof vi.fn>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { resolveProvider } from "@/lib/ai/provider";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import type { DataPoint, DataSummary } from "@/lib/analytics/trends";
|
||||
import { summarize } from "@/lib/analytics/trends";
|
||||
import { getBpTargets } from "@/lib/analytics/bp-targets";
|
||||
@@ -23,6 +23,7 @@ import { getMedicationCategories } from "@/lib/medication-category";
|
||||
import { apiHandler, requireAuth, type AuthContext } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator";
|
||||
import {
|
||||
@@ -34,6 +35,13 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// v1.15.20 — shared analytics-read budget (generous; caps runaway loops).
|
||||
const rl = await checkAnalyticsReadRateLimit(user.id);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many analytics requests. Please retry later.", 429);
|
||||
}
|
||||
|
||||
// v1.4.31 — comprehensive feeds the hero strip narration and the
|
||||
// recommendations grid that share the Coach gate.
|
||||
await requireAssistantSurface("coach");
|
||||
|
||||
@@ -17,6 +17,12 @@ vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// v1.15.20 — the route checks the shared analytics-read budget before any
|
||||
// DB work; the real helper would hit the unmocked `$queryRaw`.
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkAnalyticsReadRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
@@ -35,6 +41,7 @@ vi.mock("next/headers", () => ({
|
||||
import { GET } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -43,6 +50,12 @@ const SESSION_OK = {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
// v1.15.20 — default to an allowing analytics-read budget.
|
||||
vi.mocked(checkAnalyticsReadRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 119,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
// v1.4.31 — default to an all-on flag set so existing assertions
|
||||
// ride through unchanged. Tests that need the gate-off path set
|
||||
// their own findUnique resolution.
|
||||
@@ -118,4 +131,20 @@ describe("GET /api/insights/correlations", () => {
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("returns 429 when the shared analytics-read budget is exhausted", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkAnalyticsReadRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(429);
|
||||
expect(vi.mocked(checkAnalyticsReadRateLimit)).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
);
|
||||
// The limited request never reaches the series reads.
|
||||
expect(prisma.measurement.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -16,8 +16,9 @@
|
||||
* fetches + day-keys + responds. No LLM, no narrative, no cache table.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getLocalDateParts } from "@/lib/timezone";
|
||||
@@ -65,6 +66,13 @@ function toDailyMeans(
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// v1.15.20 — shared analytics-read budget (generous; caps runaway loops).
|
||||
const rl = await checkAnalyticsReadRateLimit(user.id);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many analytics requests. Please retry later.", 429);
|
||||
}
|
||||
|
||||
// Operator can hide the correlation surface entirely.
|
||||
await requireAssistantSurface("correlations");
|
||||
|
||||
|
||||
@@ -17,6 +17,17 @@ vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
// v1.15.20 — the route checks the shared analytics-read budget before any
|
||||
// DB work; the real helper would hit the unmocked `$queryRaw`. This file
|
||||
// uses `clearAllMocks` (implementations survive), so the factory default
|
||||
// is enough.
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkAnalyticsReadRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 119,
|
||||
resetAt: 0,
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
|
||||
@@ -20,9 +20,10 @@
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
@@ -50,6 +51,13 @@ const derivedQuerySchema = z.object({
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// v1.15.20 — shared analytics-read budget (generous; caps runaway loops).
|
||||
const rl = await checkAnalyticsReadRateLimit(user.id);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many analytics requests. Please retry later.", 429);
|
||||
}
|
||||
|
||||
await requireAssistantSurface("insightStatus");
|
||||
|
||||
const parsed = derivedQuerySchema.safeParse({
|
||||
|
||||
@@ -33,6 +33,26 @@ 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.
|
||||
vi.mock("@/lib/cache/server-cache", () => ({
|
||||
cached: 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: 59,
|
||||
resetAt: Date.now() + 60_000,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
@@ -51,6 +71,8 @@ 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 { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { getUserTodayBounds } from "@/lib/timezone";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
|
||||
@@ -106,6 +128,17 @@ function medication(schedules: unknown[]) {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
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(
|
||||
async (_cache: unknown, _key: string, builder: () => Promise<unknown>) =>
|
||||
builder(),
|
||||
);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 59,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
});
|
||||
|
||||
async function callRoute(): Promise<Record<string, unknown>> {
|
||||
@@ -353,3 +386,84 @@ describe("GET /api/medications/[id]/compliance — complianceDisplay", () => {
|
||||
expect(display.longDays).toBeGreaterThan(30);
|
||||
});
|
||||
});
|
||||
|
||||
// v1.15.20 — server cache + bounded event read + per-user rate limit.
|
||||
describe("GET /api/medications/[id]/compliance — caching and limits", () => {
|
||||
function mockDailyMed() {
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
|
||||
medication([
|
||||
{
|
||||
id: "sched-1",
|
||||
windowStart: "08:00",
|
||||
windowEnd: "09:00",
|
||||
timesOfDay: ["08:00"],
|
||||
daysOfWeek: null,
|
||||
rrule: null,
|
||||
rollingIntervalDays: null,
|
||||
reminderGraceMinutes: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
},
|
||||
]) as never,
|
||||
);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
}
|
||||
|
||||
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(new Request("http://localhost"), ROUTE_PARAMS);
|
||||
expect(res.status).toBe(429);
|
||||
expect(vi.mocked(checkRateLimit)).toHaveBeenCalledWith(
|
||||
"medication-compliance:user-1",
|
||||
60,
|
||||
60_000,
|
||||
);
|
||||
// The limited request never touches the medication tables.
|
||||
expect(prisma.medication.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reads through the per-user compliance cache key", async () => {
|
||||
mockDailyMed();
|
||||
|
||||
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];
|
||||
// `${userId}|...` prefix is what `invalidateUserMedications` sweeps.
|
||||
expect(key).toBe("user-1|med-1|compliance");
|
||||
});
|
||||
|
||||
it("bounds the intake-event read to a trailing window and the consumed columns", async () => {
|
||||
mockDailyMed();
|
||||
|
||||
const res = await GET(new Request("http://localhost"), ROUTE_PARAMS);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const call = vi.mocked(prisma.medicationIntakeEvent.findMany).mock
|
||||
.calls[0][0] as {
|
||||
where: { scheduledFor?: { gte?: Date } };
|
||||
select?: Record<string, boolean>;
|
||||
};
|
||||
// Lower-bounded: the widest served window is 365 days (+1 day margin).
|
||||
const gte = call.where.scheduledFor?.gte;
|
||||
expect(gte).toBeInstanceOf(Date);
|
||||
const minExpected = Date.now() - 367 * 24 * 60 * 60 * 1000;
|
||||
expect((gte as Date).getTime()).toBeGreaterThan(minExpected);
|
||||
// Column-bounded: only the four fields the math consumes.
|
||||
expect(call.select).toEqual({
|
||||
takenAt: true,
|
||||
skipped: true,
|
||||
scheduledFor: true,
|
||||
autoMissed: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,81 +3,37 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import {
|
||||
buildComplianceDisplay,
|
||||
buildComplianceMedicationContext,
|
||||
calculateCompliance,
|
||||
buildMedicationComplianceBundle,
|
||||
lastNonSkippedTakenAt,
|
||||
type ComplianceDisplay,
|
||||
type ComplianceResult,
|
||||
type DailyComplianceEntry,
|
||||
} from "@/lib/analytics/compliance";
|
||||
import type { DailyComplianceEntry } from "@/lib/analytics/compliance";
|
||||
import type { SlotBand } from "@/lib/medications/scheduling/attribution";
|
||||
import {
|
||||
buildBandsForSchedules,
|
||||
type BandMinterMedication,
|
||||
} from "@/lib/medications/scheduling/band-minter";
|
||||
import {
|
||||
reconstructDoseHistory,
|
||||
type DoseHistoryRow,
|
||||
type HistoryIntake,
|
||||
} from "@/lib/medications/scheduling/dose-history";
|
||||
import {
|
||||
type CanonicalSchedule,
|
||||
type RecurrenceContext,
|
||||
} from "@/lib/medications/scheduling/recurrence";
|
||||
import { normaliseDoseWindows } from "@/lib/medications/scheduling/worker-helpers";
|
||||
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";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
type MedicationScheduleRow = {
|
||||
id: string;
|
||||
rrule: string | null;
|
||||
rollingIntervalDays: number | null;
|
||||
timesOfDay: string[];
|
||||
daysOfWeek: string | null;
|
||||
windowStart: string;
|
||||
windowEnd: string;
|
||||
reminderGraceMinutes: number | null;
|
||||
scheduleType: "SCHEDULED" | "PRN" | "CYCLIC";
|
||||
cyclicOnWeeks: number | null;
|
||||
cyclicOffWeeks: number | null;
|
||||
/** v1.15.18 — per-dose configurable on-time windows (persisted JSON). */
|
||||
doseWindows: unknown;
|
||||
};
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* v1.15.18 — adapt a Prisma schedule row to the canonical engine shape the
|
||||
* band minter consumes. A legacy daily row carrying only `windowStart`
|
||||
* surfaces it as the single `timeOfDay` so the minter mints its daily band.
|
||||
* 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.
|
||||
*/
|
||||
function toCanonicalForBands(
|
||||
s: MedicationScheduleRow,
|
||||
oneShot: boolean,
|
||||
): CanonicalSchedule {
|
||||
const base: CanonicalSchedule = {
|
||||
id: s.id,
|
||||
rrule: s.rrule,
|
||||
rollingIntervalDays: s.rollingIntervalDays,
|
||||
timesOfDay: s.timesOfDay,
|
||||
daysOfWeek: s.daysOfWeek,
|
||||
windowStart: s.windowStart,
|
||||
windowEnd: s.windowEnd,
|
||||
reminderGraceMinutes: s.reminderGraceMinutes,
|
||||
scheduleType: s.scheduleType,
|
||||
cyclicOnWeeks: s.cyclicOnWeeks,
|
||||
cyclicOffWeeks: s.cyclicOffWeeks,
|
||||
doseWindows: normaliseDoseWindows(s.doseWindows),
|
||||
};
|
||||
if (
|
||||
base.timesOfDay.length === 0 &&
|
||||
base.rrule === null &&
|
||||
base.rollingIntervalDays === null &&
|
||||
base.scheduleType !== "PRN" &&
|
||||
!oneShot
|
||||
) {
|
||||
return { ...base, timesOfDay: [base.windowStart] };
|
||||
}
|
||||
return base;
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -134,11 +90,153 @@ function bucketLedgerRow(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.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.
|
||||
const rl = await checkRateLimit(
|
||||
`medication-compliance:${user.id}`,
|
||||
60,
|
||||
60_000,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
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;
|
||||
@@ -152,167 +250,19 @@ export const GET = apiHandler(
|
||||
return apiError("Medication not found", 404);
|
||||
}
|
||||
|
||||
const events = await prisma.medicationIntakeEvent.findMany({
|
||||
// v1.7.0 sync — exclude tombstoned rows from the compliance read.
|
||||
where: { medicationId: id, userId: user.id, deletedAt: null },
|
||||
orderBy: { scheduledFor: "desc" },
|
||||
});
|
||||
|
||||
const mapped = events.map((e) => ({
|
||||
takenAt: e.takenAt,
|
||||
skipped: e.skipped,
|
||||
scheduledFor: e.scheduledFor,
|
||||
// v1.15.9 — a forgotten dose the auto-miss cron flipped counts as a
|
||||
// miss, not a neutral skip.
|
||||
autoMissed: e.autoMissed,
|
||||
}));
|
||||
|
||||
const createdAt = medication.createdAt;
|
||||
|
||||
// v1.7.0 SB-SCHED-2 — thread the medication context so the
|
||||
// denominator routes through the canonical engine (RRULE / rolling /
|
||||
// one-shot / PRN / cyclic) instead of the legacy daysOfWeek walker.
|
||||
// `lastIntakeAt` is the latest non-skipped takenAt (rolling cadences
|
||||
// re-anchor on it); the events list is already ordered scheduledFor
|
||||
// desc, so scan for the max takenAt.
|
||||
const lastIntakeAt = lastNonSkippedTakenAt(mapped);
|
||||
const userTz = user.timezone || "Europe/Berlin";
|
||||
const medicationContext = buildComplianceMedicationContext(
|
||||
medication,
|
||||
lastIntakeAt,
|
||||
userTz,
|
||||
|
||||
// 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.
|
||||
const payload = await cached(
|
||||
caches.medicationCompliance as ServerCache<CompliancePayload>,
|
||||
`${user.id}|${id}|compliance`,
|
||||
() => buildCompliancePayload(medication, user.id, userTz),
|
||||
annotate,
|
||||
);
|
||||
|
||||
// v1.15.9 — pin a single `now` and thread it into every cadence
|
||||
// computation. The two `calculateCompliance` calls + the display windows
|
||||
// can otherwise straddle a day boundary on a slow request, so a dose
|
||||
// would count in one window and not the next within the same response.
|
||||
const now = new Date();
|
||||
|
||||
const compliance7 = calculateCompliance(mapped, medication.schedules, 7, createdAt, {
|
||||
now,
|
||||
medicationContext,
|
||||
});
|
||||
const compliance30 = calculateCompliance(
|
||||
mapped,
|
||||
medication.schedules,
|
||||
30,
|
||||
createdAt,
|
||||
{ now, medicationContext },
|
||||
);
|
||||
|
||||
// v1.8.6 — the two-row compliance display. The card always shows two
|
||||
// percentage rows; the server scales the two windows to the dosing
|
||||
// cadence (dense meds keep 7 / 30 days, sparse meds step both windows
|
||||
// up) and computes each row's rate over the chosen span. `compliance7`
|
||||
// / `compliance30` above are untouched — iOS + the Health Score read
|
||||
// them verbatim.
|
||||
const complianceDisplay = buildComplianceDisplay(
|
||||
mapped,
|
||||
medication.schedules,
|
||||
medicationContext,
|
||||
{ now },
|
||||
);
|
||||
|
||||
// v1.15.18 — the heatmap / line-chart daily map is now 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 legacy
|
||||
// per-day `classifyIntakeTiming` 3h-grace heuristic + `expectedSlotCount
|
||||
// ForDay` walk are retired here in favour of band membership: a take inside
|
||||
// a slot's on-time band reads on-time, inside its late tail reads late,
|
||||
// outside every band reads ad-hoc; an unfilled slot past its miss cutoff
|
||||
// reads missed; a future slot is upcoming (not yet due).
|
||||
const dailyCompliance: Record<string, DailyComplianceEntry> = {};
|
||||
|
||||
// Build the bands across the whole 90-day heatmap window once. The window
|
||||
// floor clamps to the medication's creation so pre-existence days never
|
||||
// mint phantom slots.
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const windowFrom = new Date(
|
||||
Math.max(now.getTime() - 90 * DAY_MS, createdAt.getTime()),
|
||||
);
|
||||
const bandMedication: BandMinterMedication = {
|
||||
id: medication.id,
|
||||
startsOn: medication.startsOn,
|
||||
endsOn: medication.endsOn,
|
||||
oneShot: medication.oneShot,
|
||||
createdAt: medication.createdAt,
|
||||
};
|
||||
const bandCtx: RecurrenceContext = {
|
||||
medication: {
|
||||
id: medication.id,
|
||||
startsOn: medication.startsOn,
|
||||
endsOn: medication.endsOn,
|
||||
oneShot: medication.oneShot,
|
||||
createdAt: medication.createdAt,
|
||||
},
|
||||
timeZone: userTz,
|
||||
lastIntakeAt,
|
||||
};
|
||||
const canonicalSchedules: CanonicalSchedule[] = medication.schedules.map(
|
||||
(s) => toCanonicalForBands(s, medication.oneShot),
|
||||
);
|
||||
const intakeInstants = mapped
|
||||
.filter((e) => !e.skipped && e.takenAt !== null && e.takenAt <= now)
|
||||
.map((e) => e.takenAt as Date)
|
||||
.sort((a, b) => a.getTime() - b.getTime());
|
||||
const groups = buildBandsForSchedules({
|
||||
medication: bandMedication,
|
||||
schedules: canonicalSchedules,
|
||||
ctx: bandCtx,
|
||||
userTz,
|
||||
range: { from: windowFrom, to: now },
|
||||
now,
|
||||
intakeInstants,
|
||||
});
|
||||
const bands: SlotBand[] = [];
|
||||
for (const g of groups) {
|
||||
if (g.hasExpectedSlots) bands.push(...g.bands);
|
||||
}
|
||||
const historyIntakes: HistoryIntake[] = mapped
|
||||
.filter((e) => e.scheduledFor >= windowFrom && e.scheduledFor <= now)
|
||||
.map((e) => ({
|
||||
scheduledFor: e.scheduledFor,
|
||||
takenAt: e.takenAt,
|
||||
skipped: e.skipped,
|
||||
autoMissed: e.autoMissed,
|
||||
}));
|
||||
const ledgerRows = reconstructDoseHistory(bands, historyIntakes, now);
|
||||
|
||||
// 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.
|
||||
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;
|
||||
};
|
||||
|
||||
for (const row of ledgerRows) {
|
||||
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;
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "medication.compliance",
|
||||
@@ -320,18 +270,13 @@ export const GET = apiHandler(
|
||||
entity_id: id,
|
||||
},
|
||||
meta: {
|
||||
compliance7: compliance7.rate,
|
||||
compliance30: compliance30.rate,
|
||||
complianceShortDays: complianceDisplay.shortDays,
|
||||
complianceLongDays: complianceDisplay.longDays,
|
||||
compliance7: payload.compliance7.rate,
|
||||
compliance30: payload.compliance30.rate,
|
||||
complianceShortDays: payload.complianceDisplay.shortDays,
|
||||
complianceLongDays: payload.complianceDisplay.longDays,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
compliance7,
|
||||
compliance30,
|
||||
dailyCompliance,
|
||||
complianceDisplay,
|
||||
});
|
||||
return apiSuccess(payload);
|
||||
},
|
||||
);
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import {
|
||||
recomputeMedicationComplianceForDay,
|
||||
dayKeyForScheduledFor,
|
||||
@@ -158,6 +159,13 @@ export const POST = apiHandler(
|
||||
}
|
||||
}
|
||||
|
||||
// v1.15.20 — imported intakes change every cached medication read
|
||||
// (list, intake caches, compliance payload). This write path was the
|
||||
// one intake surface missing the flush.
|
||||
if (imported > 0) {
|
||||
invalidateUserMedications(user.id);
|
||||
}
|
||||
|
||||
await auditLog("medication.intake.import", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
|
||||
@@ -56,7 +56,12 @@ export default function MedicationDetailPage({
|
||||
if (!res.ok) throw new Error("medication_detail_failed");
|
||||
return (await res.json()).data as MedicationDetailSnapshot;
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
// v1.15.20 — deliberately NOT gated on `isAuthenticated`: the session
|
||||
// cookie rides the fetch either way, so waiting for `/api/auth/me`
|
||||
// serialised two round-trips into a waterfall on every detail
|
||||
// navigation. An unauthenticated visit fails fast with a 401 here and
|
||||
// the redirect effect above still routes to the login page (the
|
||||
// render guard below keeps the spinner up until it does).
|
||||
// A deleted medication 404s here. Don't burn a retry/backoff cycle on
|
||||
// a resource that no longer exists — the delete handler evicts this
|
||||
// key on success, but `retry: false` hardens any caller that
|
||||
@@ -64,7 +69,7 @@ export default function MedicationDetailPage({
|
||||
retry: false,
|
||||
});
|
||||
|
||||
if (authLoading || isLoading) {
|
||||
if (authLoading || isLoading || !isAuthenticated) {
|
||||
return (
|
||||
<div
|
||||
className="flex h-64 items-center justify-center"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -119,6 +119,7 @@ export default function MedicationsPage() {
|
||||
const { isAuthenticated, isLoading: authLoading } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const searchParams = useSearchParams();
|
||||
// v1.5.4 — the retired `/medications/new` route redirects here with
|
||||
// `?new=1`, so legacy bookmarks keep landing on the create wizard.
|
||||
@@ -160,20 +161,33 @@ export default function MedicationsPage() {
|
||||
setDialogOpen(true);
|
||||
}
|
||||
|
||||
// v1.15.20 — seed the detail page's query from the list row before
|
||||
// navigating. The list response is a superset of the detail GET
|
||||
// (`{...medication, category, nextDueAt}` plus the list-only
|
||||
// `lastTakenAt` / `todayEventCount`), so the detail shell paints
|
||||
// instantly from the seeded cache while its own un-gated query
|
||||
// refetches the authoritative row in the background.
|
||||
function seedDetail(med: Medication) {
|
||||
queryClient.setQueryData(queryKeys.medicationDetail(med.id), med);
|
||||
}
|
||||
|
||||
// v1.15.18 — the card kebab actions now navigate to the full-page
|
||||
// tabbed detail surface on the right tab rather than opening a modal
|
||||
// editor / sheet. Edit lands on Zeitplan (the everyday schedule view +
|
||||
// the hero's "Vollständig bearbeiten" jump into the wizard), History on
|
||||
// Verlauf, Advanced on Erweitert (the dissolved advanced-settings).
|
||||
function openEdit(med: Medication) {
|
||||
seedDetail(med);
|
||||
router.push(`/medications/${med.id}?tab=zeitplan`);
|
||||
}
|
||||
|
||||
function openHistory(med: Medication) {
|
||||
seedDetail(med);
|
||||
router.push(`/medications/${med.id}?tab=verlauf`);
|
||||
}
|
||||
|
||||
function openAdvanced(med: Medication) {
|
||||
seedDetail(med);
|
||||
router.push(`/medications/${med.id}?tab=erweitert`);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,13 @@ import {
|
||||
ReferenceDot,
|
||||
} from "recharts";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useState, useMemo, useEffect, type ComponentType } from "react";
|
||||
import {
|
||||
useState,
|
||||
useMemo,
|
||||
useEffect,
|
||||
useCallback,
|
||||
type ComponentType,
|
||||
} from "react";
|
||||
import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip";
|
||||
import { ChartEmptyState } from "./chart-empty-state";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
@@ -607,10 +613,17 @@ export function HealthChart({
|
||||
// and a fresh measurement evicts every per-chart daily cache in one
|
||||
// pass (audit C2). The factory-packed tuple is byte-identical with
|
||||
// the pre-v1.4.40 inline literal so the cache layout stays stable.
|
||||
// v1.15.20 — BMI is a pure transformation of the WEIGHT series
|
||||
// (value / height²), so the BMI view shares the raw series' cache
|
||||
// entry instead of re-fetching the same window under its own key.
|
||||
// The key pins the literal "raw" / "no-bmi" discriminators (the
|
||||
// factory tuple shape is unchanged) and the BMI division happens in
|
||||
// the `select` callback below — the factory rule stays intact and
|
||||
// the cached data is mode-agnostic.
|
||||
queryKey: queryKeys.chartData(
|
||||
types.join(","),
|
||||
valueMode,
|
||||
bmiDivisor ?? "no-bmi",
|
||||
"raw",
|
||||
"no-bmi",
|
||||
// v1.4.25 W7b — bucket keys + tick labels depend on the active
|
||||
// user timezone, so re-key the cache when it changes. Without
|
||||
// this, a tz change inside a session would render stale buckets.
|
||||
@@ -745,15 +758,12 @@ export function HealthChart({
|
||||
// personal baseline, the trend, and the tooltip all operate
|
||||
// on the scaled series uniformly. `valueScale` defaults to 1
|
||||
// (identity), so non-unit-fixed charts read byte-identical.
|
||||
const rawValue = measurement.value * valueScale;
|
||||
const value =
|
||||
valueMode === "bmi"
|
||||
? bmiDivisor
|
||||
? rawValue / bmiDivisor
|
||||
: null
|
||||
: rawValue;
|
||||
// v1.15.20 — the BMI division moved into the query's `select`
|
||||
// callback so the cached series stays raw and the WEIGHT and
|
||||
// BMI views share one cache entry.
|
||||
const value = measurement.value * valueScale;
|
||||
|
||||
if (value == null || !Number.isFinite(value)) {
|
||||
if (!Number.isFinite(value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -773,11 +783,13 @@ export function HealthChart({
|
||||
// v1.8.5 — carry the rollup bucket's min / max through when the
|
||||
// API supplies them (daily-aggregate path, non-cumulative).
|
||||
// `valueScale` already folded into `value` above; apply the
|
||||
// same scale to the spread so the band tracks the line.
|
||||
// same scale to the spread so the band tracks the line. The
|
||||
// BMI view drops the range tuple in its `select` callback (it
|
||||
// has never rendered the band), so the raw cache always
|
||||
// carries the spread.
|
||||
if (
|
||||
typeof measurement.minValue === "number" &&
|
||||
typeof measurement.maxValue === "number" &&
|
||||
valueMode !== "bmi"
|
||||
typeof measurement.maxValue === "number"
|
||||
) {
|
||||
const scaledMin = measurement.minValue * valueScale;
|
||||
const scaledMax = measurement.maxValue * valueScale;
|
||||
@@ -826,6 +838,33 @@ export function HealthChart({
|
||||
|
||||
return allData;
|
||||
},
|
||||
// v1.15.20 — BMI view: transform the cached raw WEIGHT series into
|
||||
// BMI at read time. The transformation lives in `select` (not the
|
||||
// queryFn) so the cache entry stays raw and is shared with the
|
||||
// weight chart; tanstack re-runs the projection without a refetch.
|
||||
// No height on the profile → empty series (matches the previous
|
||||
// behaviour where every point was skipped). The `${type}__range`
|
||||
// tuples are dropped — the BMI view has never rendered the band.
|
||||
select: useCallback(
|
||||
(points: ChartDataPoint[]): ChartDataPoint[] => {
|
||||
if (valueMode !== "bmi") return points;
|
||||
if (!bmiDivisor) return [];
|
||||
return points.map((p) => {
|
||||
const out: ChartDataPoint = {
|
||||
date: p.date,
|
||||
timestamp: p.timestamp,
|
||||
};
|
||||
for (const type of types) {
|
||||
const v = p[type];
|
||||
if (typeof v === "number") {
|
||||
out[type] = v / bmiDivisor;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
});
|
||||
},
|
||||
[valueMode, bmiDivisor, types],
|
||||
),
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
|
||||
@@ -203,7 +203,10 @@ export function Glp1MedicationCard({
|
||||
const json = await res.json();
|
||||
return json.data as ComplianceData;
|
||||
},
|
||||
staleTime: 30 * 1000,
|
||||
// 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.4.37 W4b — same reminder-thresholds source as the generic
|
||||
|
||||
@@ -146,7 +146,12 @@ export function MedicationCard({
|
||||
const json = await res.json();
|
||||
return json.data as ComplianceData;
|
||||
},
|
||||
staleTime: 30 * 1000,
|
||||
// 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,
|
||||
});
|
||||
|
||||
const [, forceUpdate] = useReducer((x: number) => x + 1, 0);
|
||||
|
||||
@@ -14,14 +14,12 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
// v1.4.41 W-ORG — shared shape lives in `src/types/analytics.ts` as
|
||||
// `ChecklistAnalyticsData`; aliased back to the local name.
|
||||
import type { ChecklistAnalyticsData as AnalyticsData } from "@/types/analytics";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
|
||||
import { useDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
buildChecklist,
|
||||
checklistProgress,
|
||||
@@ -181,40 +179,54 @@ export function GettingStartedChecklist() {
|
||||
}
|
||||
}, [expanded]);
|
||||
|
||||
// Light-weight queries: each fetch is small and cached by tanstack.
|
||||
// We rely on the React Query cache the dashboard already uses for
|
||||
// analytics, so this won't fire a second request when the dashboard
|
||||
// and the checklist mount together. v1.4.33 IW2 routes the checklist
|
||||
// onto IW1's slim `?slice=summaries` branch — the only field
|
||||
// consumed is `summaries[METRIC].count`, which the slim slice fills
|
||||
// directly. The cache slot is distinct from the dashboard's thick
|
||||
// slice but each consumer's payload is byte-correct for its own
|
||||
// reads, and the shared hook keeps the legacy `?.data` unwrap so the
|
||||
// canonical consumers still see `{summaries, …}` not `{data: …}`.
|
||||
const analyticsQuery = useAnalyticsQuery({
|
||||
slice: "summaries",
|
||||
enabled: !!user,
|
||||
});
|
||||
const analyticsData = analyticsQuery.data as AnalyticsData | undefined;
|
||||
// v1.15.20 — the measurement count is derived from the dashboard
|
||||
// snapshot the page already fetches (`tiles.summaries` is the same
|
||||
// `computeSummariesSlice` payload the legacy `?slice=summaries`
|
||||
// analytics query carried), so the checklist no longer fires its own
|
||||
// analytics request on every dashboard load. Subscribing to the shared
|
||||
// `queryKeys.dashboardSnapshot()` cell costs zero extra network.
|
||||
const snapshotQuery = useDashboardSnapshot(!!user);
|
||||
const snapshotData = snapshotQuery.data;
|
||||
|
||||
const measurementCount = useMemo(() => {
|
||||
const summaries = snapshotData?.tiles?.summaries ?? {};
|
||||
let total = 0;
|
||||
for (const key of Object.keys(summaries)) {
|
||||
total += summaries[key]?.count ?? 0;
|
||||
}
|
||||
return total;
|
||||
}, [snapshotData]);
|
||||
|
||||
// v1.15.20 — only fetch the checklist's supporting data while the card
|
||||
// can actually render: the user is still in setup (`shouldShowChecklist`'s
|
||||
// `onboardingCompletedAt == null || measurementCount < 5` rule) and has
|
||||
// not dismissed it. Established users used to pay this medications fetch
|
||||
// (and, pre-v1.5, two more) on every dashboard load for a card that
|
||||
// never mounts. Gated on the snapshot having resolved so the count is
|
||||
// real, not the loading-default 0.
|
||||
const checklistRelevant =
|
||||
!!user &&
|
||||
!dismissedAll &&
|
||||
snapshotData !== undefined &&
|
||||
(user.onboardingCompletedAt == null || measurementCount < 5);
|
||||
|
||||
const { data: medsData } = useQuery<Array<{ id: string }>>({
|
||||
queryKey: ["medications"],
|
||||
queryKey: queryKeys.medications(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/medications");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
const json = await res.json();
|
||||
return json.data;
|
||||
},
|
||||
enabled: !!user,
|
||||
enabled: checklistRelevant,
|
||||
});
|
||||
|
||||
// v1.5 perf audit: skip these two fetches once the user is past
|
||||
// onboarding. The checklist hides itself anyway via shouldShowChecklist
|
||||
// when onboardingCompletedAt != null AND measurementCount >= 5; the
|
||||
// analytics + medications queries above piggy-back on the dashboard's
|
||||
// shared cache, but withings/status and notifications/preferences are
|
||||
// unique to this component and were burning ~950 ms of network on every
|
||||
// dashboard load for established users. See docs/audit/v15-performance.md.
|
||||
// when onboardingCompletedAt != null AND measurementCount >= 5;
|
||||
// withings/status and notifications/preferences are unique to this
|
||||
// component and were burning ~950 ms of network on every dashboard load
|
||||
// for established users. See docs/audit/v15-performance.md.
|
||||
const onboardingPending = !!user && user.onboardingCompletedAt == null;
|
||||
|
||||
const { data: withingsData } = useQuery<WithingsStatus>({
|
||||
@@ -239,15 +251,6 @@ export function GettingStartedChecklist() {
|
||||
enabled: onboardingPending,
|
||||
});
|
||||
|
||||
const measurementCount = useMemo(() => {
|
||||
const summaries = analyticsData?.summaries ?? {};
|
||||
let total = 0;
|
||||
for (const key of Object.keys(summaries)) {
|
||||
total += summaries[key]?.count ?? 0;
|
||||
}
|
||||
return total;
|
||||
}, [analyticsData]);
|
||||
|
||||
const medicationCount = medsData?.length ?? 0;
|
||||
const withingsConnected = withingsData?.connected === true;
|
||||
const notificationsConfigured = (notificationsData?.channels ?? []).some(
|
||||
@@ -291,19 +294,19 @@ export function GettingStartedChecklist() {
|
||||
});
|
||||
|
||||
// v1.4.15 phase-A3 fix #3 — flicker guard. Until BOTH the auth user
|
||||
// AND the analytics query have resolved we render NOTHING. The
|
||||
// AND the snapshot query have resolved we render NOTHING. The
|
||||
// previous version rendered a default-true `show` branch while
|
||||
// `measurementCount` was still 0 (because analytics hadn't returned),
|
||||
// `measurementCount` was still 0 (because the data hadn't returned),
|
||||
// so a user whose actual `measurementCount >= 5` saw the card flash
|
||||
// for ~500 ms before `shouldShowChecklist` flipped to false. We rely
|
||||
// on `analyticsQuery.data === undefined` as the loading sentinel —
|
||||
// on `snapshotData === undefined` as the loading sentinel —
|
||||
// tanstack-query writes `data` exactly once per fetch, so this is
|
||||
// race-free across hot-reloads and stale-cache invalidation. (The
|
||||
// dashboard's `/api/analytics` query shares the cache key, so the
|
||||
// checklist almost always sees the cached value synchronously on
|
||||
// mount and never blocks UI.)
|
||||
// dashboard's snapshot query shares the cache key, so the checklist
|
||||
// almost always sees the cached value synchronously on mount and
|
||||
// never blocks UI.)
|
||||
if (!user) return null;
|
||||
if (analyticsQuery.data === undefined) return null;
|
||||
if (snapshotData === undefined) return null;
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* v1.15.20 — single-pass compliance bundle parity.
|
||||
*
|
||||
* `buildMedicationComplianceBundle` replaces the per-medication compliance
|
||||
* route's five-plus expansion passes (calculateCompliance × 4, the window
|
||||
* ladder probes, the heatmap mint) with ONE band expansion + ONE cadence
|
||||
* timeline. These tests pin the bundle's blocks against the historical
|
||||
* per-window composition so the refactor cannot drift the public payload:
|
||||
* every block must be deep-equal to what `calculateCompliance` /
|
||||
* `buildComplianceDisplay` produce for the same fixture.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
buildComplianceMedicationContext,
|
||||
buildComplianceLedgerRows,
|
||||
buildMedicationComplianceBundle,
|
||||
buildComplianceDisplay,
|
||||
calculateCompliance,
|
||||
lastNonSkippedTakenAt,
|
||||
tallyComplianceFromLedger,
|
||||
tallyLedgerRows,
|
||||
type ComplianceSchedule,
|
||||
} from "../compliance";
|
||||
|
||||
const TZ = "UTC";
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Deterministic anchor — keeps every window fully in the fixture's hands. */
|
||||
const NOW = new Date("2026-06-01T12:00:00Z");
|
||||
|
||||
function dailySchedule(): ComplianceSchedule {
|
||||
return {
|
||||
windowStart: "08:00",
|
||||
windowEnd: "09:00",
|
||||
timesOfDay: ["08:00"],
|
||||
daysOfWeek: null,
|
||||
rrule: null,
|
||||
rollingIntervalDays: null,
|
||||
reminderGraceMinutes: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
};
|
||||
}
|
||||
|
||||
function rollingSchedule(intervalDays: number): ComplianceSchedule {
|
||||
return {
|
||||
windowStart: "10:00",
|
||||
windowEnd: "11:00",
|
||||
timesOfDay: ["10:00"],
|
||||
daysOfWeek: null,
|
||||
rrule: null,
|
||||
rollingIntervalDays: intervalDays,
|
||||
reminderGraceMinutes: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** A slot instant `daysAgo` before NOW at the given "HH:mm" (UTC fixture). */
|
||||
function slotAt(daysAgo: number, hhmm: string): Date {
|
||||
const day = new Date(NOW.getTime() - daysAgo * DAY_MS);
|
||||
const [h, m] = hhmm.split(":").map(Number);
|
||||
const d = new Date(day);
|
||||
d.setUTCHours(h, m, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
interface FixtureEvent {
|
||||
takenAt: Date | null;
|
||||
skipped: boolean;
|
||||
scheduledFor: Date;
|
||||
autoMissed?: boolean;
|
||||
}
|
||||
|
||||
function dailyFixtureEvents(): FixtureEvent[] {
|
||||
return [
|
||||
// Taken on time across several days.
|
||||
{ takenAt: slotAt(1, "08:05"), skipped: false, scheduledFor: slotAt(1, "08:00") },
|
||||
{ takenAt: slotAt(2, "08:10"), skipped: false, scheduledFor: slotAt(2, "08:00") },
|
||||
{ takenAt: slotAt(4, "07:55"), skipped: false, scheduledFor: slotAt(4, "08:00") },
|
||||
// A deliberate skip (excluded from the denominator).
|
||||
{ takenAt: null, skipped: true, scheduledFor: slotAt(3, "08:00") },
|
||||
// A taken-late dose (inside the late tail).
|
||||
{ takenAt: slotAt(5, "10:30"), skipped: false, scheduledFor: slotAt(5, "08:00") },
|
||||
// Day 6 has no event → the unfilled slot reads missed.
|
||||
// An auto-missed forgotten dose.
|
||||
{
|
||||
takenAt: null,
|
||||
skipped: false,
|
||||
scheduledFor: slotAt(8, "08:00"),
|
||||
autoMissed: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
describe("buildMedicationComplianceBundle — parity with the per-window composition", () => {
|
||||
it("daily medication: every block equals the legacy multi-pass output", () => {
|
||||
const createdAt = new Date(NOW.getTime() - 40 * DAY_MS);
|
||||
const schedules = [dailySchedule()];
|
||||
const events = dailyFixtureEvents();
|
||||
const ctx = buildComplianceMedicationContext(
|
||||
{ startsOn: null, endsOn: null, oneShot: false, createdAt },
|
||||
lastNonSkippedTakenAt(events),
|
||||
TZ,
|
||||
);
|
||||
|
||||
const bundle = buildMedicationComplianceBundle(events, schedules, ctx, NOW);
|
||||
|
||||
expect(bundle.compliance7).toEqual(
|
||||
calculateCompliance(events, schedules, 7, createdAt, {
|
||||
now: NOW,
|
||||
medicationContext: ctx,
|
||||
}),
|
||||
);
|
||||
expect(bundle.compliance30).toEqual(
|
||||
calculateCompliance(events, schedules, 30, createdAt, {
|
||||
now: NOW,
|
||||
medicationContext: ctx,
|
||||
}),
|
||||
);
|
||||
expect(bundle.complianceDisplay).toEqual(
|
||||
buildComplianceDisplay(events, schedules, ctx, { now: NOW }),
|
||||
);
|
||||
|
||||
// The ledger window clamps to the medication's creation.
|
||||
expect(bundle.ledgerFrom).toEqual(createdAt);
|
||||
// One band per daily slot over the 40-day life → 40 or 41 slot rows
|
||||
// (the boundary day depends on the creation instant vs slot time).
|
||||
const slotRows = bundle.ledgerRows.filter((r) => r.kind === "slot");
|
||||
expect(slotRows.length).toBeGreaterThanOrEqual(39);
|
||||
expect(slotRows.length).toBeLessThanOrEqual(41);
|
||||
});
|
||||
|
||||
it("rolling 35-day medication: display windows + rates match the legacy path", () => {
|
||||
const createdAt = new Date(NOW.getTime() - 400 * DAY_MS);
|
||||
const schedules = [rollingSchedule(35)];
|
||||
const events: FixtureEvent[] = [
|
||||
{
|
||||
takenAt: new Date(NOW.getTime() - 25 * DAY_MS),
|
||||
skipped: false,
|
||||
scheduledFor: new Date(NOW.getTime() - 25 * DAY_MS),
|
||||
},
|
||||
{
|
||||
takenAt: new Date(NOW.getTime() - 60 * DAY_MS),
|
||||
skipped: false,
|
||||
scheduledFor: new Date(NOW.getTime() - 60 * DAY_MS),
|
||||
},
|
||||
];
|
||||
const ctx = buildComplianceMedicationContext(
|
||||
{
|
||||
startsOn: new Date(NOW.getTime() - 380 * DAY_MS),
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
createdAt,
|
||||
},
|
||||
lastNonSkippedTakenAt(events),
|
||||
TZ,
|
||||
);
|
||||
|
||||
const bundle = buildMedicationComplianceBundle(events, schedules, ctx, NOW);
|
||||
|
||||
expect(bundle.compliance7).toEqual(
|
||||
calculateCompliance(events, schedules, 7, createdAt, {
|
||||
now: NOW,
|
||||
medicationContext: ctx,
|
||||
}),
|
||||
);
|
||||
expect(bundle.compliance30).toEqual(
|
||||
calculateCompliance(events, schedules, 30, createdAt, {
|
||||
now: NOW,
|
||||
medicationContext: ctx,
|
||||
}),
|
||||
);
|
||||
expect(bundle.complianceDisplay).toEqual(
|
||||
buildComplianceDisplay(events, schedules, ctx, { now: NOW }),
|
||||
);
|
||||
// A 35-day cadence cannot clear the stability floor on [7, 30].
|
||||
expect(bundle.complianceDisplay.shortDays).toBeGreaterThan(7);
|
||||
expect(bundle.complianceDisplay.longDays).toBeGreaterThan(30);
|
||||
});
|
||||
|
||||
it("empty schedule list: zeros short-circuit matches calculateCompliance", () => {
|
||||
const createdAt = new Date(NOW.getTime() - 10 * DAY_MS);
|
||||
const ctx = buildComplianceMedicationContext(
|
||||
{ startsOn: null, endsOn: null, oneShot: false, createdAt },
|
||||
null,
|
||||
TZ,
|
||||
);
|
||||
|
||||
const bundle = buildMedicationComplianceBundle([], [], ctx, NOW);
|
||||
|
||||
expect(bundle.compliance7).toEqual(
|
||||
calculateCompliance([], [], 7, createdAt, {
|
||||
now: NOW,
|
||||
medicationContext: ctx,
|
||||
}),
|
||||
);
|
||||
expect(bundle.complianceDisplay).toEqual(
|
||||
buildComplianceDisplay([], [], ctx, { now: NOW }),
|
||||
);
|
||||
expect(bundle.ledgerRows).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tallyLedgerRows — windowed tallies over one shared ledger", () => {
|
||||
it("the unwindowed tally equals tallyComplianceFromLedger byte-for-byte", () => {
|
||||
const createdAt = new Date(NOW.getTime() - 40 * DAY_MS);
|
||||
const schedules = [dailySchedule()];
|
||||
const events = dailyFixtureEvents();
|
||||
const ctx = buildComplianceMedicationContext(
|
||||
{ startsOn: null, endsOn: null, oneShot: false, createdAt },
|
||||
lastNonSkippedTakenAt(events),
|
||||
TZ,
|
||||
);
|
||||
const from = new Date(NOW.getTime() - 30 * DAY_MS);
|
||||
|
||||
const rows = buildComplianceLedgerRows(events, schedules, ctx, from, NOW, NOW);
|
||||
expect(tallyLedgerRows(rows)).toEqual(
|
||||
tallyComplianceFromLedger(events, schedules, ctx, from, NOW, NOW),
|
||||
);
|
||||
});
|
||||
|
||||
it("a sub-window tally counts only rows inside the window", () => {
|
||||
const createdAt = new Date(NOW.getTime() - 40 * DAY_MS);
|
||||
const schedules = [dailySchedule()];
|
||||
const events = dailyFixtureEvents();
|
||||
const ctx = buildComplianceMedicationContext(
|
||||
{ startsOn: null, endsOn: null, oneShot: false, createdAt },
|
||||
lastNonSkippedTakenAt(events),
|
||||
TZ,
|
||||
);
|
||||
|
||||
const rows = buildComplianceLedgerRows(
|
||||
events,
|
||||
schedules,
|
||||
ctx,
|
||||
createdAt,
|
||||
NOW,
|
||||
NOW,
|
||||
);
|
||||
const wide = tallyLedgerRows(rows);
|
||||
// 3.5 days reaches back past the day −3 08:00 slot (NOW is 12:00Z, so a
|
||||
// plain 3-day bound would cut that morning slot off).
|
||||
const narrow = tallyLedgerRows(rows, {
|
||||
from: new Date(NOW.getTime() - 3.5 * DAY_MS),
|
||||
to: NOW,
|
||||
});
|
||||
|
||||
// Days −1 and −2 are taken on time; day −3 is a skip. Everything
|
||||
// older falls outside the window.
|
||||
expect(narrow.takenOnTime).toBe(2);
|
||||
expect(narrow.skipped).toBe(1);
|
||||
expect(narrow.denominator).toBeLessThan(wide.denominator);
|
||||
});
|
||||
});
|
||||
@@ -36,6 +36,7 @@ import { DOSE_WINDOW_DEFAULTS } from "@/lib/medications/scheduling/dose-window-d
|
||||
import { normaliseDoseWindows } from "@/lib/medications/scheduling/worker-helpers";
|
||||
import {
|
||||
reconstructDoseHistory,
|
||||
type DoseHistoryRow,
|
||||
type HistoryIntake,
|
||||
} from "@/lib/medications/scheduling/dose-history";
|
||||
import {
|
||||
@@ -1155,6 +1156,39 @@ export function tallyComplianceFromLedger(
|
||||
now: Date,
|
||||
windowConfig?: DoseWindowConfig,
|
||||
): LedgerComplianceTally {
|
||||
const rows = buildComplianceLedgerRows(
|
||||
events,
|
||||
schedules,
|
||||
ctx,
|
||||
from,
|
||||
to,
|
||||
now,
|
||||
windowConfig,
|
||||
);
|
||||
return tallyLedgerRows(rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint the cadence-aware bands over `[from, to]` and reconstruct the ONE
|
||||
* unified dose-history ledger for them. This is the expansion pass behind
|
||||
* {@link tallyComplianceFromLedger}, extracted so a caller that needs
|
||||
* several trailing sub-windows (7-day / 30-day / display / heatmap, all
|
||||
* ending at `now`) can mint the bands ONCE over the widest window and tally
|
||||
* each sub-window from the same rows via {@link tallyLedgerRows} instead of
|
||||
* re-expanding per window.
|
||||
*
|
||||
* Pure / synchronous: bands come from pre-fetched schedules + intake
|
||||
* instants; no DB access.
|
||||
*/
|
||||
export function buildComplianceLedgerRows(
|
||||
events: IntakeEvent[],
|
||||
schedules: ComplianceSchedule[],
|
||||
ctx: ComplianceMedicationContext,
|
||||
from: Date,
|
||||
to: Date,
|
||||
now: Date,
|
||||
windowConfig?: DoseWindowConfig,
|
||||
): DoseHistoryRow[] {
|
||||
const medication: BandMinterMedication = {
|
||||
id: "compliance-tally",
|
||||
startsOn: ctx.startsOn,
|
||||
@@ -1222,14 +1256,31 @@ export function tallyComplianceFromLedger(
|
||||
autoMissed: e.autoMissed ?? false,
|
||||
}));
|
||||
|
||||
const rows = reconstructDoseHistory(bands, intakes, now);
|
||||
return reconstructDoseHistory(bands, intakes, now);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tally pre-built ledger rows into the {@link LedgerComplianceTally}
|
||||
* counters. When `window` is supplied only rows whose instant (`row.at`)
|
||||
* falls inside `[window.from, window.to]` (inclusive) are counted — that is
|
||||
* how a sub-window tally is carved out of a wider single-pass ledger. The
|
||||
* window-less call tallies every row, byte-identical to the historical
|
||||
* {@link tallyComplianceFromLedger} behaviour.
|
||||
*/
|
||||
export function tallyLedgerRows(
|
||||
rows: DoseHistoryRow[],
|
||||
window?: { from: Date; to: Date },
|
||||
): LedgerComplianceTally {
|
||||
let takenOnTime = 0;
|
||||
let takenLate = 0;
|
||||
let missed = 0;
|
||||
let skipped = 0;
|
||||
let adHoc = 0;
|
||||
for (const row of rows) {
|
||||
if (window) {
|
||||
const t = row.at.getTime();
|
||||
if (t < window.from.getTime() || t > window.to.getTime()) continue;
|
||||
}
|
||||
switch (row.status) {
|
||||
case "taken_on_time":
|
||||
takenOnTime++;
|
||||
@@ -1517,3 +1568,277 @@ export function calculateCompliance(
|
||||
|
||||
return { totalExpected, taken, skipped, missed, rate, streak };
|
||||
}
|
||||
|
||||
/**
|
||||
* Widest trailing window the single-pass compliance ledger has to cover:
|
||||
* the top rung of {@link COMPLIANCE_WINDOW_LADDER} (365 days). Every
|
||||
* sub-window the per-medication compliance endpoint serves (7 / 30 /
|
||||
* cadence-scaled display rows / 90-day heatmap) is a suffix of it.
|
||||
*/
|
||||
export const COMPLIANCE_LEDGER_WINDOW_DAYS = 365;
|
||||
|
||||
/**
|
||||
* The per-medication compliance payload computed from ONE shared expansion
|
||||
* pass. `ledgerRows` is the unified dose-history ledger over
|
||||
* `[ledgerFrom, now]`; the caller carves the 90-day heatmap out of it by
|
||||
* filtering on `row.at`.
|
||||
*/
|
||||
export interface MedicationComplianceBundle {
|
||||
compliance7: ComplianceResult;
|
||||
compliance30: ComplianceResult;
|
||||
complianceDisplay: ComplianceDisplay;
|
||||
/** Unified ledger rows over `[ledgerFrom, now]`, chronological. */
|
||||
ledgerRows: DoseHistoryRow[];
|
||||
/** Lower bound of the mint window (clamped to the medication's creation). */
|
||||
ledgerFrom: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build every block of the per-medication compliance response from a single
|
||||
* band-expansion pass.
|
||||
*
|
||||
* The historical composition called {@link calculateCompliance} four times
|
||||
* (7 / 30 / short / long), {@link selectComplianceWindows} (up to four more
|
||||
* occurrence expansions for the rung probes) and a separate 90-day heatmap
|
||||
* mint — five-plus full band expansions per request. This builder instead:
|
||||
*
|
||||
* 1. mints the bands + reconstructs the ledger ONCE over
|
||||
* `[max(createdAt, now − 365 d), now]` (every served window is a
|
||||
* suffix of that range and ends at `now`, so a sub-window tally is a
|
||||
* filter over `row.at`, not a re-expansion);
|
||||
* 2. builds ONE cadence timeline over the same range for the per-window
|
||||
* streaks ({@link streaksFromTimeline} bounds its day-walk by the
|
||||
* requested window, so the wider timeline serves every window);
|
||||
* 3. walks the {@link COMPLIANCE_WINDOW_LADDER} on the ledger's slot-row
|
||||
* counts (one band per engine occurrence, so the counts match the
|
||||
* historical `expectedSlotsBetween` probes).
|
||||
*
|
||||
* The returned blocks carry the exact public shapes the route has always
|
||||
* served — only the number of expansion passes changed.
|
||||
*/
|
||||
export function buildMedicationComplianceBundle(
|
||||
events: IntakeEvent[],
|
||||
schedules: ComplianceSchedule[],
|
||||
ctx: ComplianceMedicationContext,
|
||||
now: Date,
|
||||
): MedicationComplianceBundle {
|
||||
const ledgerPeriodStart = new Date(
|
||||
now.getTime() - COMPLIANCE_LEDGER_WINDOW_DAYS * ONE_DAY_MS,
|
||||
);
|
||||
const ledgerFrom =
|
||||
ctx.createdAt.getTime() > ledgerPeriodStart.getTime()
|
||||
? ctx.createdAt
|
||||
: ledgerPeriodStart;
|
||||
|
||||
const hasSchedules = schedules.length > 0;
|
||||
const ledgerRows = hasSchedules
|
||||
? buildComplianceLedgerRows(events, schedules, ctx, ledgerFrom, now, now)
|
||||
: [];
|
||||
|
||||
// ONE cadence timeline over the full ledger window. Each per-window
|
||||
// streak below walks only its own trailing `effectiveDays`, so sharing
|
||||
// the wide timeline reproduces the per-window builds.
|
||||
const fullDays = Math.max(
|
||||
1,
|
||||
Math.ceil((now.getTime() - ledgerFrom.getTime()) / ONE_DAY_MS),
|
||||
);
|
||||
const timeline = hasSchedules
|
||||
? buildTimelineForWindow(events, schedules, ctx, now, ledgerFrom, fullDays)
|
||||
: [];
|
||||
|
||||
const resultForWindow = (days: number): ComplianceResult => {
|
||||
if (!hasSchedules) {
|
||||
// Mirrors `calculateCompliance`'s empty-schedule short-circuit.
|
||||
return {
|
||||
totalExpected: 0,
|
||||
taken: 0,
|
||||
skipped: 0,
|
||||
missed: 0,
|
||||
rate: 100,
|
||||
streak: 0,
|
||||
};
|
||||
}
|
||||
const periodStart = new Date(now.getTime() - days * ONE_DAY_MS);
|
||||
const effectiveStart =
|
||||
ctx.createdAt.getTime() > periodStart.getTime()
|
||||
? ctx.createdAt
|
||||
: periodStart;
|
||||
const effectiveDays = Math.max(
|
||||
1,
|
||||
Math.ceil((now.getTime() - effectiveStart.getTime()) / ONE_DAY_MS),
|
||||
);
|
||||
const tally = tallyLedgerRows(ledgerRows, { from: effectiveStart, to: now });
|
||||
const { current: streak } = streaksFromTimeline(
|
||||
timeline,
|
||||
now,
|
||||
effectiveDays,
|
||||
ctx.timeZone,
|
||||
);
|
||||
return {
|
||||
totalExpected: tally.taken + tally.skipped + tally.missed,
|
||||
taken: tally.taken,
|
||||
skipped: tally.skipped,
|
||||
missed: tally.missed,
|
||||
rate: tally.rate,
|
||||
streak,
|
||||
};
|
||||
};
|
||||
|
||||
// Window-ladder selection from the ledger's slot rows. A slot row is one
|
||||
// minted band, and the minter emits one band per engine occurrence, so
|
||||
// counting slot rows over a trailing window equals the historical
|
||||
// `expectedSlotsBetween(...).length` probe for that window.
|
||||
const expectedCache = new Map<number, number>();
|
||||
const expectedOver = (days: number): number => {
|
||||
const hit = expectedCache.get(days);
|
||||
if (hit !== undefined) return hit;
|
||||
const from = Math.max(
|
||||
ctx.createdAt.getTime(),
|
||||
now.getTime() - days * ONE_DAY_MS,
|
||||
);
|
||||
let count = 0;
|
||||
for (const row of ledgerRows) {
|
||||
if (row.kind !== "slot") continue;
|
||||
const t = row.at.getTime();
|
||||
if (t >= from && t <= now.getTime()) count++;
|
||||
}
|
||||
expectedCache.set(days, count);
|
||||
return count;
|
||||
};
|
||||
|
||||
let selection: ComplianceWindowSelection | null = null;
|
||||
for (const [shortDays, longDays] of COMPLIANCE_WINDOW_LADDER) {
|
||||
const expectedShort = expectedOver(shortDays);
|
||||
const expectedLong = expectedOver(longDays);
|
||||
if (expectedShort >= MIN_STABLE_DOSES && expectedLong >= MIN_STABLE_DOSES) {
|
||||
selection = { shortDays, longDays, expectedShort, expectedLong };
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!selection) {
|
||||
// No rung cleared the floor — fall back to the widest rung, exactly
|
||||
// like `selectComplianceWindows`.
|
||||
const [shortDays, longDays] =
|
||||
COMPLIANCE_WINDOW_LADDER[COMPLIANCE_WINDOW_LADDER.length - 1];
|
||||
selection = {
|
||||
shortDays,
|
||||
longDays,
|
||||
expectedShort: expectedOver(shortDays),
|
||||
expectedLong: expectedOver(longDays),
|
||||
};
|
||||
}
|
||||
|
||||
const compliance7 = resultForWindow(7);
|
||||
const compliance30 = resultForWindow(30);
|
||||
const short = resultForWindow(selection.shortDays);
|
||||
const long = resultForWindow(selection.longDays);
|
||||
|
||||
const currentCycle = buildCurrentCycle(
|
||||
schedules,
|
||||
ctx,
|
||||
now,
|
||||
selection.expectedShort,
|
||||
events,
|
||||
);
|
||||
const currentDose: { status: DoseStatus; targetAt: Date | null } =
|
||||
currentCycle.nextDueAt
|
||||
? {
|
||||
status: deriveDoseStatus(
|
||||
currentCycle.nextDueAt,
|
||||
soonestCadenceFamily(schedules),
|
||||
now,
|
||||
),
|
||||
targetAt: currentCycle.nextDueAt,
|
||||
}
|
||||
: { status: "upcoming", targetAt: null };
|
||||
|
||||
const complianceDisplay: ComplianceDisplay = {
|
||||
shortDays: selection.shortDays,
|
||||
longDays: selection.longDays,
|
||||
expectedShort: selection.expectedShort,
|
||||
expectedLong: selection.expectedLong,
|
||||
minStableDoses: MIN_STABLE_DOSES,
|
||||
short: {
|
||||
rate: short.rate,
|
||||
taken: short.taken,
|
||||
expected: short.taken + short.missed,
|
||||
missed: short.missed,
|
||||
streak: short.streak,
|
||||
},
|
||||
long: {
|
||||
rate: long.rate,
|
||||
taken: long.taken,
|
||||
expected: long.taken + long.missed,
|
||||
missed: long.missed,
|
||||
},
|
||||
currentCycle,
|
||||
currentDose,
|
||||
};
|
||||
|
||||
return { compliance7, compliance30, complianceDisplay, ledgerRows, ledgerFrom };
|
||||
}
|
||||
|
||||
/**
|
||||
* The cadence-timeline construction from {@link calculateCompliance},
|
||||
* extracted so {@link buildMedicationComplianceBundle} can build it once
|
||||
* over the full ledger window instead of once per served sub-window. Same
|
||||
* normalisation, same engine context, same rolling-retrospective routing.
|
||||
*/
|
||||
function buildTimelineForWindow(
|
||||
events: IntakeEvent[],
|
||||
schedules: ComplianceSchedule[],
|
||||
ctx: ComplianceMedicationContext,
|
||||
now: Date,
|
||||
effectiveStart: Date,
|
||||
effectiveDays: number,
|
||||
): ReturnType<typeof buildCadenceTimeline> {
|
||||
const normalisedSchedules: ScheduleLike[] = schedules.map((s, i) => ({
|
||||
id: `compliance-${i}`,
|
||||
windowStart: s.windowStart,
|
||||
windowEnd: s.windowEnd,
|
||||
daysOfWeek: s.daysOfWeek ?? null,
|
||||
rrule: s.rrule ?? null,
|
||||
rollingIntervalDays: s.rollingIntervalDays ?? null,
|
||||
timesOfDay: s.timesOfDay,
|
||||
reminderGraceMinutes: s.reminderGraceMinutes ?? null,
|
||||
scheduleType: s.scheduleType ?? null,
|
||||
cyclicOnWeeks: s.cyclicOnWeeks ?? null,
|
||||
cyclicOffWeeks: s.cyclicOffWeeks ?? null,
|
||||
}));
|
||||
|
||||
const engineCtx: CadenceEngineContext = {
|
||||
startsOn: ctx.startsOn,
|
||||
endsOn: ctx.endsOn,
|
||||
oneShot: ctx.oneShot,
|
||||
createdAt: ctx.createdAt,
|
||||
lastIntakeAt: ctx.lastIntakeAt,
|
||||
timeZone: ctx.timeZone,
|
||||
};
|
||||
|
||||
const normalisedEvents: IntakeEventLike[] = events
|
||||
.filter((e) => e.scheduledFor >= effectiveStart && e.scheduledFor <= now)
|
||||
.map((e) => ({
|
||||
scheduledFor: e.scheduledFor,
|
||||
takenAt: e.takenAt,
|
||||
skipped: e.skipped,
|
||||
autoMissed: e.autoMissed ?? false,
|
||||
}));
|
||||
|
||||
const hasRolling = schedules.some(
|
||||
(s) => s.rollingIntervalDays != null && s.rollingIntervalDays > 0,
|
||||
);
|
||||
const retro = hasRolling
|
||||
? { intakeInstants: rollingIntakeInstants(events, now), now }
|
||||
: undefined;
|
||||
|
||||
return buildCadenceTimeline(
|
||||
normalisedSchedules,
|
||||
normalisedEvents,
|
||||
now,
|
||||
effectiveDays,
|
||||
ctx.createdAt,
|
||||
ctx.timeZone,
|
||||
engineCtx,
|
||||
retro,
|
||||
);
|
||||
}
|
||||
+26
@@ -50,6 +50,18 @@ async function primeAllCaches(): Promise<void> {
|
||||
c: 2,
|
||||
}));
|
||||
|
||||
// v1.15.20 — per-medication compliance payload cache.
|
||||
await cached(
|
||||
caches.medicationCompliance,
|
||||
`${USER_A}|med-1|compliance`,
|
||||
async () => ({ mc: 1 }),
|
||||
);
|
||||
await cached(
|
||||
caches.medicationCompliance,
|
||||
`${USER_B}|med-1|compliance`,
|
||||
async () => ({ mc: 2 }),
|
||||
);
|
||||
|
||||
await cached(caches.achievements, USER_A, async () => ({ ach: 1 }));
|
||||
await cached(caches.achievements, USER_B, async () => ({ ach: 2 }));
|
||||
|
||||
@@ -149,6 +161,20 @@ describe("invalidateUserMedications", () => {
|
||||
expect(caches.medications.get(USER_B)).not.toBeNull();
|
||||
expect(caches.medicationsIntake.get(`${USER_B}|compliance|30`)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("evicts the per-medication compliance payload for the target user only", async () => {
|
||||
await primeAllCaches();
|
||||
invalidateUserMedications(USER_A);
|
||||
|
||||
// v1.15.20 — the compliance route caches 15 min; an intake write must
|
||||
// flush the `${userId}|` prefix so the next card read is fresh.
|
||||
expect(
|
||||
caches.medicationCompliance.get(`${USER_A}|med-1|compliance`),
|
||||
).toBeNull();
|
||||
expect(
|
||||
caches.medicationCompliance.get(`${USER_B}|med-1|compliance`),
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalidateUserDashboardWidgets", () => {
|
||||
|
||||
Vendored
+4
@@ -105,6 +105,10 @@ export function invalidateUserMood(userId: string): void {
|
||||
export function invalidateUserMedications(userId: string): void {
|
||||
caches.medications.deleteByPrefix(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).
|
||||
|
||||
Vendored
+16
@@ -393,6 +393,22 @@ export const caches = {
|
||||
maxEntries: 1000,
|
||||
ttlMs: 900_000,
|
||||
}),
|
||||
/**
|
||||
* v1.15.20 — per-medication compliance payload
|
||||
* (`GET /api/medications/[id]/compliance`). The medications list fans
|
||||
* the endpoint out once per card, and each cold build runs the full
|
||||
* band-expansion pass — so the warm path has to live server-side.
|
||||
* Keyed `${userId}|${medicationId}|compliance`; every intake /
|
||||
* medication write flushes the `${userId}|` prefix via
|
||||
* `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.
|
||||
*/
|
||||
medicationCompliance: new ServerCache<unknown>({
|
||||
name: "medicationCompliance",
|
||||
maxEntries: 2000,
|
||||
ttlMs: 900_000,
|
||||
}),
|
||||
moodAnalytics: new ServerCache<unknown>({
|
||||
name: "moodAnalytics",
|
||||
maxEntries: 1000,
|
||||
|
||||
@@ -64,6 +64,29 @@ export function rateLimitHeaders(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.15.20 — shared per-user budget for the heavy read-only analytics
|
||||
* surfaces (`/api/insights/comprehensive`, `/api/insights/correlations`,
|
||||
* `/api/insights/derived`, `/api/analytics/range`). The insights page
|
||||
* legitimately fans several of these out per mount, so the bucket is
|
||||
* generous — 120/min only caps a runaway client loop or a scripted
|
||||
* drain, mirroring the `sync:changes` budget. One shared bucket (not
|
||||
* per-surface) keeps the cap meaningful when a loop rotates across
|
||||
* endpoints.
|
||||
*/
|
||||
const ANALYTICS_READ_LIMIT = 120;
|
||||
const ANALYTICS_READ_WINDOW_MS = 60 * 1000;
|
||||
|
||||
export async function checkAnalyticsReadRateLimit(
|
||||
userId: string,
|
||||
): Promise<RateLimitResult> {
|
||||
return checkRateLimit(
|
||||
`analytics-read:${userId}`,
|
||||
ANALYTICS_READ_LIMIT,
|
||||
ANALYTICS_READ_WINDOW_MS,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.43 W13 M-4 — anonymous/trust-violation bucket key shared by every
|
||||
* caller of `checkAuthSurfaceRateLimit`. When `TRUST_PROXY_HOPS` and the
|
||||
|
||||
Reference in new issue
Block a user