mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
style: apply prettier across the tree
One-shot prettier pass so the new format:check gate starts from a clean baseline. Formatting only — no behaviour change. Generator-owned and vendored artifacts stay untouched via .prettierignore.
This commit is contained in:
1 parent
b3fea2ef59
commit
1f4ba5939d
1000 files changed
+12130
-8385
No files matched your search
@@ -90,9 +90,9 @@ describe("isTolerableRequestProbeError — narrow-catch classifier", () => {
|
||||
expect(isTolerableRequestProbeError(new Error("private member #x"))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
isTolerableRequestProbeError(new RangeError("private member")),
|
||||
).toBe(false);
|
||||
expect(isTolerableRequestProbeError(new RangeError("private member"))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isTolerableRequestProbeError("private member string")).toBe(false);
|
||||
expect(isTolerableRequestProbeError(null)).toBe(false);
|
||||
expect(isTolerableRequestProbeError(undefined)).toBe(false);
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { z } from "zod/v4";
|
||||
import {
|
||||
returnAllZodIssues,
|
||||
sanitiseZodIssues,
|
||||
} from "../api-response";
|
||||
import { returnAllZodIssues, sanitiseZodIssues } from "../api-response";
|
||||
|
||||
// v1.4.42 W2 — every Zod-validated route historically returned
|
||||
// `parsed.error.issues[0].message`, dropping every issue past the first.
|
||||
@@ -28,7 +25,9 @@ describe("returnAllZodIssues", () => {
|
||||
const body = (await response.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
details: { issues: Array<{ path: string; code: string; message: string }> };
|
||||
details: {
|
||||
issues: Array<{ path: string; code: string; message: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.error).toBe("Validation failed");
|
||||
|
||||
@@ -15,14 +15,41 @@ const PNG_1X1 = Buffer.from(
|
||||
|
||||
/** Minimal JPEG: SOI + APP0 + SOF0(1×1) + EOI. */
|
||||
const JPEG_1X1 = Buffer.from([
|
||||
0xff, 0xd8, // SOI
|
||||
0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00,
|
||||
0x01, 0x00, 0x01, 0x00, 0x00,
|
||||
0xff, 0xc0, 0x00, 0x0b, 0x08,
|
||||
0x00, 0x01, // height = 1
|
||||
0x00, 0x01, // width = 1
|
||||
0x01, 0x01, 0x11, 0x00,
|
||||
0xff, 0xd9, // EOI
|
||||
0xff,
|
||||
0xd8, // SOI
|
||||
0xff,
|
||||
0xe0,
|
||||
0x00,
|
||||
0x10,
|
||||
0x4a,
|
||||
0x46,
|
||||
0x49,
|
||||
0x46,
|
||||
0x00,
|
||||
0x01,
|
||||
0x01,
|
||||
0x00,
|
||||
0x00,
|
||||
0x01,
|
||||
0x00,
|
||||
0x01,
|
||||
0x00,
|
||||
0x00,
|
||||
0xff,
|
||||
0xc0,
|
||||
0x00,
|
||||
0x0b,
|
||||
0x08,
|
||||
0x00,
|
||||
0x01, // height = 1
|
||||
0x00,
|
||||
0x01, // width = 1
|
||||
0x01,
|
||||
0x01,
|
||||
0x11,
|
||||
0x00,
|
||||
0xff,
|
||||
0xd9, // EOI
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
Binary file not shown.
@@ -10,14 +10,7 @@
|
||||
* `window` shape the helper actually reads, so the test runs under
|
||||
* the default node environment without pulling in `jsdom`.
|
||||
*/
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
interface Globals {
|
||||
document?: { cookie: string };
|
||||
|
||||
@@ -106,9 +106,9 @@ describe("formatDateOrRelative", () => {
|
||||
});
|
||||
|
||||
it("accepts a Date instance as well as ISO string", () => {
|
||||
expect(
|
||||
formatDateOrRelative(new Date(NOW - 30 * MINUTE), fakeT, NOW),
|
||||
).toBe("insights.relativeMinutesAgoOther:count=30");
|
||||
expect(formatDateOrRelative(new Date(NOW - 30 * MINUTE), fakeT, NOW)).toBe(
|
||||
"insights.relativeMinutesAgoOther:count=30",
|
||||
);
|
||||
});
|
||||
|
||||
// v1.4.49.2 — regression guard. The helper's `t()` calls must dispatch
|
||||
@@ -121,16 +121,39 @@ describe("formatDateOrRelative", () => {
|
||||
it("emits only keys that exist in the translation bundle", async () => {
|
||||
const en = await import("../../../messages/en.json");
|
||||
const bundleKeysCalled = new Set<string>();
|
||||
const collectingT = (key: string, params?: Record<string, string | number>) => {
|
||||
const collectingT = (
|
||||
key: string,
|
||||
params?: Record<string, string | number>,
|
||||
) => {
|
||||
bundleKeysCalled.add(key);
|
||||
return params ? `${key}:${JSON.stringify(params)}` : key;
|
||||
};
|
||||
// Drive every relative bucket so we hit `t()` on each branch.
|
||||
formatDateOrRelative(new Date(NOW - 30 * SECOND).toISOString(), collectingT, NOW);
|
||||
formatDateOrRelative(new Date(NOW - 1 * MINUTE).toISOString(), collectingT, NOW);
|
||||
formatDateOrRelative(new Date(NOW - 30 * MINUTE).toISOString(), collectingT, NOW);
|
||||
formatDateOrRelative(new Date(NOW - 1 * HOUR).toISOString(), collectingT, NOW);
|
||||
formatDateOrRelative(new Date(NOW - 12 * HOUR).toISOString(), collectingT, NOW);
|
||||
formatDateOrRelative(
|
||||
new Date(NOW - 30 * SECOND).toISOString(),
|
||||
collectingT,
|
||||
NOW,
|
||||
);
|
||||
formatDateOrRelative(
|
||||
new Date(NOW - 1 * MINUTE).toISOString(),
|
||||
collectingT,
|
||||
NOW,
|
||||
);
|
||||
formatDateOrRelative(
|
||||
new Date(NOW - 30 * MINUTE).toISOString(),
|
||||
collectingT,
|
||||
NOW,
|
||||
);
|
||||
formatDateOrRelative(
|
||||
new Date(NOW - 1 * HOUR).toISOString(),
|
||||
collectingT,
|
||||
NOW,
|
||||
);
|
||||
formatDateOrRelative(
|
||||
new Date(NOW - 12 * HOUR).toISOString(),
|
||||
collectingT,
|
||||
NOW,
|
||||
);
|
||||
expect(bundleKeysCalled.size).toBeGreaterThan(0);
|
||||
// `insights.*` carries a mix of string and nested-object children
|
||||
// (e.g. `personalRecord: { badge, tooltip }`), so the bundle type
|
||||
|
||||
@@ -75,10 +75,8 @@ vi.mock("node:fs", async () => {
|
||||
return false;
|
||||
};
|
||||
const readFileSync = (p: string) => {
|
||||
if (p.endsWith("GeoLite2-City.mmdb"))
|
||||
return Buffer.from("city-mmdb-stub");
|
||||
if (p.endsWith("GeoLite2-ASN.mmdb"))
|
||||
return Buffer.from("asn-mmdb-stub");
|
||||
if (p.endsWith("GeoLite2-City.mmdb")) return Buffer.from("city-mmdb-stub");
|
||||
if (p.endsWith("GeoLite2-ASN.mmdb")) return Buffer.from("asn-mmdb-stub");
|
||||
throw new Error(`unexpected read ${p}`);
|
||||
};
|
||||
return {
|
||||
@@ -219,7 +217,11 @@ describe("lookupIpLocation — offline-first city resolver (v1.4.27 B3)", () =>
|
||||
const fetchSpy = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
new TextEncoder().encode(
|
||||
JSON.stringify({ success: true, city: "Hamburg", country_code: "DE" }),
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
city: "Hamburg",
|
||||
country_code: "DE",
|
||||
}),
|
||||
).buffer as ArrayBuffer,
|
||||
{
|
||||
status: 200,
|
||||
|
||||
@@ -98,7 +98,10 @@ describe("offline-geo runtime detection (v1.4.27 R5)", () => {
|
||||
});
|
||||
|
||||
it("offlineGeoReady() is true when the City MMDB is present and no marker", async () => {
|
||||
writeFileSync(join(tmpRoot, "GeoLite2-City.mmdb"), Buffer.from("city-stub"));
|
||||
writeFileSync(
|
||||
join(tmpRoot, "GeoLite2-City.mmdb"),
|
||||
Buffer.from("city-stub"),
|
||||
);
|
||||
const { offlineGeoReady } = await import("../geo");
|
||||
expect(offlineGeoReady()).toBe(true);
|
||||
});
|
||||
@@ -130,7 +133,10 @@ describe("offline-geo runtime detection (v1.4.27 R5)", () => {
|
||||
});
|
||||
|
||||
it("does NOT fire the notification when the City MMDB is present", async () => {
|
||||
writeFileSync(join(tmpRoot, "GeoLite2-City.mmdb"), Buffer.from("city-stub"));
|
||||
writeFileSync(
|
||||
join(tmpRoot, "GeoLite2-City.mmdb"),
|
||||
Buffer.from("city-stub"),
|
||||
);
|
||||
// The stubbed reader returns null for every IP, which is fine — the
|
||||
// lookup will still go through the online path, but the offline
|
||||
// check sees a healthy directory so the notification stays silent.
|
||||
|
||||
@@ -107,16 +107,12 @@ describe("getClientIp trusted-proxy semantics (V3 audit)", () => {
|
||||
// pollute the rate-limit / audit log with non-IP strings; they are
|
||||
// now rejected at the helper.
|
||||
it("rejects structurally invalid IPv4 candidates (1.2)", () => {
|
||||
const ip = getClientIp(
|
||||
makeRequest({ "x-forwarded-for": "1.2, 5.6.7.8" }),
|
||||
);
|
||||
const ip = getClientIp(makeRequest({ "x-forwarded-for": "1.2, 5.6.7.8" }));
|
||||
expect(ip).toBe("5.6.7.8");
|
||||
});
|
||||
|
||||
it("rejects structurally invalid IPv6 candidates (:::)", () => {
|
||||
const ip = getClientIp(
|
||||
makeRequest({ "x-forwarded-for": ":::, 5.6.7.8" }),
|
||||
);
|
||||
const ip = getClientIp(makeRequest({ "x-forwarded-for": ":::, 5.6.7.8" }));
|
||||
expect(ip).toBe("5.6.7.8");
|
||||
});
|
||||
|
||||
@@ -129,9 +125,7 @@ describe("getClientIp trusted-proxy semantics (V3 audit)", () => {
|
||||
|
||||
it("accepts well-formed IPv6 addresses", () => {
|
||||
process.env.TRUST_PROXY_HOPS = "1";
|
||||
const ip = getClientIp(
|
||||
makeRequest({ "x-forwarded-for": "2001:db8::1" }),
|
||||
);
|
||||
const ip = getClientIp(makeRequest({ "x-forwarded-for": "2001:db8::1" }));
|
||||
expect(ip).toBe("2001:db8::1");
|
||||
});
|
||||
|
||||
@@ -294,12 +288,8 @@ describe("getClientIpOrTrustWarning shape (F-6, 2026-05-16)", () => {
|
||||
process.env.TRUST_PROXY_HOPS = "2";
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
try {
|
||||
getClientIpOrTrustWarning(
|
||||
makeRequest({ "x-forwarded-for": "5.6.7.8" }),
|
||||
);
|
||||
getClientIpOrTrustWarning(
|
||||
makeRequest({ "x-forwarded-for": "5.6.7.8" }),
|
||||
);
|
||||
getClientIpOrTrustWarning(makeRequest({ "x-forwarded-for": "5.6.7.8" }));
|
||||
getClientIpOrTrustWarning(makeRequest({ "x-forwarded-for": "5.6.7.8" }));
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
warn.mockRestore();
|
||||
|
||||
@@ -189,7 +189,9 @@ describe("serializeInsightsLayout — defaults + dense order", () => {
|
||||
tiles: [{ id: "overview", visible: true, order: 0 }],
|
||||
});
|
||||
// Sorted by input order, then re-numbered 0,1,2.
|
||||
expect(serialized.sections.map((s) => ({ id: s.id, order: s.order }))).toEqual([
|
||||
expect(
|
||||
serialized.sections.map((s) => ({ id: s.id, order: s.order })),
|
||||
).toEqual([
|
||||
{ id: "trends", order: 0 },
|
||||
{ id: "vitals", order: 1 },
|
||||
{ id: "signals", order: 2 },
|
||||
@@ -333,7 +335,11 @@ describe("orderedVisibleSectionIds — W2 render-order helper", () => {
|
||||
const visible = orderedVisibleSectionIds(layout);
|
||||
// The three explicit sections lead in their saved order; the rest
|
||||
// auto-merge invisible (new-id semantics), so they do NOT appear.
|
||||
expect(visible.slice(0, 3)).toEqual(["trends", "vitals", "wellness-scores"]);
|
||||
expect(visible.slice(0, 3)).toEqual([
|
||||
"trends",
|
||||
"vitals",
|
||||
"wellness-scores",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -33,7 +33,10 @@ describe("queryKeys factory", () => {
|
||||
});
|
||||
|
||||
it("threads the slim slice through queryKeys.analytics", () => {
|
||||
expect(queryKeys.analytics("summaries")).toEqual(["analytics", "summaries"]);
|
||||
expect(queryKeys.analytics("summaries")).toEqual([
|
||||
"analytics",
|
||||
"summaries",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the no-arg call byte-identical to the legacy literal", () => {
|
||||
@@ -238,7 +241,9 @@ describe("dependent-key bundles", () => {
|
||||
// (audit-L4).
|
||||
it("medicationDependentKeys bundles the dashboard-medication-compliance prefix (v1.4.40)", () => {
|
||||
const keyStrings = medicationDependentKeys.map((k) => JSON.stringify(k));
|
||||
expect(keyStrings).toContain(JSON.stringify(["dashboard-medication-compliance"]));
|
||||
expect(keyStrings).toContain(
|
||||
JSON.stringify(["dashboard-medication-compliance"]),
|
||||
);
|
||||
});
|
||||
|
||||
// v1.5.5 D-3 §10 invariant 20 — the per-medication inline compliance
|
||||
@@ -334,7 +339,8 @@ describe("queryKey factory enforcement", () => {
|
||||
}
|
||||
|
||||
it("no guarded file declares a bare-literal `queryKey:` (factory enforcement)", () => {
|
||||
const offenders: Array<{ file: string; line: number; snippet: string }> = [];
|
||||
const offenders: Array<{ file: string; line: number; snippet: string }> =
|
||||
[];
|
||||
const files = collect();
|
||||
for (const file of files) {
|
||||
const text = readFileSync(file, "utf8");
|
||||
|
||||
@@ -17,13 +17,22 @@ describe("reference-ranges backbone", () => {
|
||||
it("every band + conflict + headline resolves a real citation", () => {
|
||||
for (const metric of REFERENCE_METRICS) {
|
||||
const range = REFERENCE_RANGES[metric];
|
||||
expect(CITATIONS[range.referenceId], `${metric} referenceId`).toBeTruthy();
|
||||
expect(
|
||||
CITATIONS[range.referenceId],
|
||||
`${metric} referenceId`,
|
||||
).toBeTruthy();
|
||||
expect(range.unit, `${metric} unit`).toBeTruthy();
|
||||
expect(range.guidanceCaveat, `${metric} guidanceCaveat`).toBeTruthy();
|
||||
for (const band of range.bands) {
|
||||
expect(CITATIONS[band.citation], `${metric} band ${band.label}`).toBeTruthy();
|
||||
expect(
|
||||
CITATIONS[band.citation],
|
||||
`${metric} band ${band.label}`,
|
||||
).toBeTruthy();
|
||||
// A band must pin at least one bound.
|
||||
expect(band.low != null || band.high != null, `${metric} ${band.label}`).toBe(true);
|
||||
expect(
|
||||
band.low != null || band.high != null,
|
||||
`${metric} ${band.label}`,
|
||||
).toBe(true);
|
||||
}
|
||||
for (const conflict of range.conflicts ?? []) {
|
||||
expect(CITATIONS[conflict.citation], `${metric} conflict`).toBeTruthy();
|
||||
@@ -62,14 +71,18 @@ describe("reference-ranges backbone", () => {
|
||||
const marked = bands.filter((b) => b.normal === true);
|
||||
expect(marked, `${metric} normal-band count`).toHaveLength(1);
|
||||
// `normalBandIndex` points at that marked band.
|
||||
expect(bands[normalBandIndex(metric)].normal, `${metric} index`).toBe(true);
|
||||
expect(bands[normalBandIndex(metric)].normal, `${metric} index`).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves the normal band by marker, not by index 0", () => {
|
||||
// BMI authors Underweight FIRST; the normal band is the 18.5–24.9 entry.
|
||||
expect(REFERENCE_RANGES.BMI.bands[0].label).toBe("Underweight");
|
||||
expect(REFERENCE_RANGES.BMI.bands[normalBandIndex("BMI")].label).toBe("Normal");
|
||||
expect(REFERENCE_RANGES.BMI.bands[normalBandIndex("BMI")].label).toBe(
|
||||
"Normal",
|
||||
);
|
||||
// BMI 22 is healthy → within (the index-0 bug reported it slightly-outside).
|
||||
expect(classifyReference("BMI", 22)).toBe("within");
|
||||
// BMI 17 is underweight → one band below normal → slightly-outside
|
||||
@@ -93,10 +106,14 @@ describe("reference-ranges backbone", () => {
|
||||
|
||||
it("returns insufficient for absent values and band-less metrics", () => {
|
||||
expect(classifyReference("RESTING_HEART_RATE", null)).toBe("insufficient");
|
||||
expect(classifyReference("RESTING_HEART_RATE", Number.NaN)).toBe("insufficient");
|
||||
expect(classifyReference("RESTING_HEART_RATE", Number.NaN)).toBe(
|
||||
"insufficient",
|
||||
);
|
||||
// HRV carries no fixed population band — baseline-only.
|
||||
expect(REFERENCE_RANGES.HEART_RATE_VARIABILITY.bands).toHaveLength(0);
|
||||
expect(classifyReference("HEART_RATE_VARIABILITY", 42)).toBe("insufficient");
|
||||
expect(classifyReference("HEART_RATE_VARIABILITY", 42)).toBe(
|
||||
"insufficient",
|
||||
);
|
||||
});
|
||||
|
||||
it("steps backbone uses the canonical 8,000 green floor", () => {
|
||||
|
||||
@@ -67,18 +67,16 @@ describe("pinnedPublicDispatcher", () => {
|
||||
it("refuses a hostname that resolves to a private IP", async () => {
|
||||
// Mock dns.lookup to return 169.254.169.254 (cloud metadata).
|
||||
const lookupSpy = vi.spyOn(dns, "lookup");
|
||||
lookupSpy.mockImplementation(
|
||||
((
|
||||
_hostname: string,
|
||||
_opts: dns.LookupAllOptions,
|
||||
callback: (
|
||||
err: NodeJS.ErrnoException | null,
|
||||
addresses: dns.LookupAddress[],
|
||||
) => void,
|
||||
) => {
|
||||
callback(null, [{ address: "169.254.169.254", family: 4 }]);
|
||||
}) as unknown as typeof dns.lookup,
|
||||
);
|
||||
lookupSpy.mockImplementation(((
|
||||
_hostname: string,
|
||||
_opts: dns.LookupAllOptions,
|
||||
callback: (
|
||||
err: NodeJS.ErrnoException | null,
|
||||
addresses: dns.LookupAddress[],
|
||||
) => void,
|
||||
) => {
|
||||
callback(null, [{ address: "169.254.169.254", family: 4 }]);
|
||||
}) as unknown as typeof dns.lookup);
|
||||
|
||||
const dispatcher = getPinnedPublicDispatcher();
|
||||
// Drive the dispatcher through `fetch` so the integration mirrors
|
||||
@@ -100,18 +98,16 @@ describe("pinnedPublicDispatcher", () => {
|
||||
// The expected outcome is therefore NOT ENOTFOUND but a connect
|
||||
// failure further down the stack (the IP refuses or times out).
|
||||
const lookupSpy = vi.spyOn(dns, "lookup");
|
||||
lookupSpy.mockImplementation(
|
||||
((
|
||||
_hostname: string,
|
||||
_opts: dns.LookupAllOptions,
|
||||
callback: (
|
||||
err: NodeJS.ErrnoException | null,
|
||||
addresses: dns.LookupAddress[],
|
||||
) => void,
|
||||
) => {
|
||||
callback(null, [{ address: "192.0.2.1", family: 4 }]);
|
||||
}) as unknown as typeof dns.lookup,
|
||||
);
|
||||
lookupSpy.mockImplementation(((
|
||||
_hostname: string,
|
||||
_opts: dns.LookupAllOptions,
|
||||
callback: (
|
||||
err: NodeJS.ErrnoException | null,
|
||||
addresses: dns.LookupAddress[],
|
||||
) => void,
|
||||
) => {
|
||||
callback(null, [{ address: "192.0.2.1", family: 4 }]);
|
||||
}) as unknown as typeof dns.lookup);
|
||||
|
||||
const dispatcher = getPinnedPublicDispatcher();
|
||||
let caught: unknown;
|
||||
@@ -135,21 +131,19 @@ describe("pinnedPublicDispatcher", () => {
|
||||
// Mock dns.lookup to return one private + one public address. The
|
||||
// dispatcher must drop the private one and pin to the public one.
|
||||
const lookupSpy = vi.spyOn(dns, "lookup");
|
||||
lookupSpy.mockImplementation(
|
||||
((
|
||||
_hostname: string,
|
||||
_opts: dns.LookupAllOptions,
|
||||
callback: (
|
||||
err: NodeJS.ErrnoException | null,
|
||||
addresses: dns.LookupAddress[],
|
||||
) => void,
|
||||
) => {
|
||||
callback(null, [
|
||||
{ address: "10.0.0.5", family: 4 },
|
||||
{ address: "192.0.2.1", family: 4 },
|
||||
]);
|
||||
}) as unknown as typeof dns.lookup,
|
||||
);
|
||||
lookupSpy.mockImplementation(((
|
||||
_hostname: string,
|
||||
_opts: dns.LookupAllOptions,
|
||||
callback: (
|
||||
err: NodeJS.ErrnoException | null,
|
||||
addresses: dns.LookupAddress[],
|
||||
) => void,
|
||||
) => {
|
||||
callback(null, [
|
||||
{ address: "10.0.0.5", family: 4 },
|
||||
{ address: "192.0.2.1", family: 4 },
|
||||
]);
|
||||
}) as unknown as typeof dns.lookup);
|
||||
|
||||
const dispatcher = getPinnedPublicDispatcher();
|
||||
let caught: unknown;
|
||||
|
||||
@@ -365,7 +365,8 @@ describe("Codex slug fallback chain", () => {
|
||||
} finally {
|
||||
if (origModel === undefined) delete process.env.CODEX_MODEL;
|
||||
else process.env.CODEX_MODEL = origModel;
|
||||
if (origChain === undefined) delete process.env.CODEX_MODEL_FALLBACK_CHAIN;
|
||||
if (origChain === undefined)
|
||||
delete process.env.CODEX_MODEL_FALLBACK_CHAIN;
|
||||
else process.env.CODEX_MODEL_FALLBACK_CHAIN = origChain;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -49,7 +49,10 @@ describe("chainRequiresServerManagedConsent", () => {
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
chainRequiresServerManagedConsent([entry("openai"), entry("admin-openai")]),
|
||||
chainRequiresServerManagedConsent([
|
||||
entry("openai"),
|
||||
entry("admin-openai"),
|
||||
]),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -28,10 +28,7 @@ describe("isLocalAiHostAllowed", () => {
|
||||
});
|
||||
|
||||
it("permits ONLY the listed hosts when given a comma-separated allowlist", () => {
|
||||
vi.stubEnv(
|
||||
"ALLOW_LOCAL_AI_PRIVATE_HOSTS",
|
||||
"ollama.lan, 10.0.0.5",
|
||||
);
|
||||
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "ollama.lan, 10.0.0.5");
|
||||
expect(isLocalAiHostAllowed("http://ollama.lan:11434/v1")).toBe(true);
|
||||
expect(isLocalAiHostAllowed("http://10.0.0.5:11434/v1")).toBe(true);
|
||||
// A different private host is still rejected — the metadata endpoint and
|
||||
|
||||
@@ -57,7 +57,13 @@ describe("coach clusters", () => {
|
||||
it("expands the default clusters to the legacy core sources + additive members", () => {
|
||||
const sources = clusterSourcesFromPrefs(undefined);
|
||||
// Legacy five domains' sources are all present.
|
||||
for (const core of ["bp", "weight", "pulse", "mood", "compliance"] as const) {
|
||||
for (const core of [
|
||||
"bp",
|
||||
"weight",
|
||||
"pulse",
|
||||
"mood",
|
||||
"compliance",
|
||||
] as const) {
|
||||
expect(sources.has(core)).toBe(true);
|
||||
}
|
||||
// Additive members ride inside cardio/body.
|
||||
|
||||
@@ -121,7 +121,8 @@ describe("refreshConversationSummary", () => {
|
||||
});
|
||||
|
||||
it("(c) merge path passes the decrypted prior summary into the user prompt", async () => {
|
||||
const prior = "Earlier: user wants better sleep and dislikes evening caffeine.";
|
||||
const prior =
|
||||
"Earlier: user wants better sleep and dislikes evening caffeine.";
|
||||
// 30 turns, no prior summaryTurnCount → forces a generation.
|
||||
const { client } = makePrisma(
|
||||
makeConversation(30, {
|
||||
@@ -162,7 +163,9 @@ describe("refreshConversationSummary", () => {
|
||||
|
||||
it("(e) success path encrypts and persists summaryEncrypted + turn count + updatedAt", async () => {
|
||||
const now = new Date("2026-06-04T08:00:00.000Z");
|
||||
const { client, update } = makePrisma(makeConversation(30, { summaryTurnCount: 0 }));
|
||||
const { client, update } = makePrisma(
|
||||
makeConversation(30, { summaryTurnCount: 0 }),
|
||||
);
|
||||
const runCompletion = vi.fn().mockResolvedValue(okCompletion);
|
||||
|
||||
const result = await refreshConversationSummary("conv-1", "user-1", {
|
||||
@@ -185,9 +188,7 @@ describe("refreshConversationSummary", () => {
|
||||
|
||||
describe("buildSummaryUserPrompt", () => {
|
||||
it("uses (none) when no prior summary is supplied", () => {
|
||||
const out = buildSummaryUserPrompt(null, [
|
||||
{ role: "user", content: "hi" },
|
||||
]);
|
||||
const out = buildSummaryUserPrompt(null, [{ role: "user", content: "hi" }]);
|
||||
expect(out).toContain("PRIOR SUMMARY\n(none)");
|
||||
expect(out).toContain("user: hi");
|
||||
});
|
||||
|
||||
@@ -73,7 +73,9 @@ function rhrMeasurements() {
|
||||
out.push({
|
||||
type: "RESTING_HEART_RATE",
|
||||
value: v + (i % 3) * 0.5,
|
||||
measuredAt: new Date(`2026-03-${String(i).padStart(2, "0")}T12:00:00.000Z`),
|
||||
measuredAt: new Date(
|
||||
`2026-03-${String(i).padStart(2, "0")}T12:00:00.000Z`,
|
||||
),
|
||||
source: "APPLE_HEALTH",
|
||||
deviceType: null,
|
||||
});
|
||||
@@ -176,6 +178,8 @@ describe("buildCycleSnapshotBlock", () => {
|
||||
expect(block!.phaseInsight).not.toBeNull();
|
||||
expect(block!.phaseInsight!.metric).toBe("resting heart rate");
|
||||
expect(block!.phaseInsight!.delta).toBeGreaterThan(0);
|
||||
expect(block!.phaseInsight!.interpretation).toMatch(/descriptive pattern, not a cause/);
|
||||
expect(block!.phaseInsight!.interpretation).toMatch(
|
||||
/descriptive pattern, not a cause/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/insights/derived", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/insights/derived")>();
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/lib/insights/derived")>();
|
||||
return { ...actual, computeDerivedMetric: vi.fn() };
|
||||
});
|
||||
|
||||
@@ -14,8 +15,9 @@ import { prisma } from "@/lib/db";
|
||||
import { buildDerivedSnapshotBlock } from "../derived-snapshot";
|
||||
|
||||
const compute = computeDerivedMetric as unknown as ReturnType<typeof vi.fn>;
|
||||
const whoopFindFirst = prisma.measurement
|
||||
.findFirst as unknown as ReturnType<typeof vi.fn>;
|
||||
const whoopFindFirst = prisma.measurement.findFirst as unknown as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
/** Make the WHOOP-native recovery probe report present / absent. */
|
||||
function setWhoopNativePresent(present: boolean) {
|
||||
whoopFindFirst.mockResolvedValue(present ? { id: "whoop-1" } : null);
|
||||
@@ -29,13 +31,28 @@ function ok(value: Record<string, unknown>, historyDays = 14) {
|
||||
value,
|
||||
coverage: { requiredInputs: 1, presentInputs: 1, historyDays, missing: [] },
|
||||
confidence: { score: 88, band: "high" as const },
|
||||
provenance: { inputs: [], source: "DAY" as const, windowDays: 14, computedAt: "x" },
|
||||
provenance: {
|
||||
inputs: [],
|
||||
source: "DAY" as const,
|
||||
windowDays: 14,
|
||||
computedAt: "x",
|
||||
},
|
||||
};
|
||||
}
|
||||
const insufficient = {
|
||||
status: "insufficient" as const,
|
||||
coverage: { requiredInputs: 1, presentInputs: 0, historyDays: 0, missing: [] },
|
||||
provenance: { inputs: [], source: "none" as const, windowDays: 0, computedAt: "x" },
|
||||
coverage: {
|
||||
requiredInputs: 1,
|
||||
presentInputs: 0,
|
||||
historyDays: 0,
|
||||
missing: [],
|
||||
},
|
||||
provenance: {
|
||||
inputs: [],
|
||||
source: "none" as const,
|
||||
windowDays: 0,
|
||||
computedAt: "x",
|
||||
},
|
||||
reason: "no_score_in_window",
|
||||
};
|
||||
|
||||
@@ -59,8 +76,10 @@ describe("buildDerivedSnapshotBlock", () => {
|
||||
// alongside readiness (the dedup only drops the COMPUTED proxy).
|
||||
setWhoopNativePresent(true);
|
||||
compute.mockImplementation(async (args?: { metric?: string }) => {
|
||||
if (args?.metric === "READINESS") return ok({ score: 64, band: "yellow" });
|
||||
if (args?.metric === "RECOVERY_SCORE") return ok({ score: 72, band: "green" });
|
||||
if (args?.metric === "READINESS")
|
||||
return ok({ score: 64, band: "yellow" });
|
||||
if (args?.metric === "RECOVERY_SCORE")
|
||||
return ok({ score: 72, band: "green" });
|
||||
return insufficient;
|
||||
});
|
||||
const block = await buildDerivedSnapshotBlock("u1", PROFILE, NOW);
|
||||
@@ -87,8 +106,10 @@ describe("buildDerivedSnapshotBlock", () => {
|
||||
// same number twice, so the recovery line is dropped.
|
||||
setWhoopNativePresent(false);
|
||||
compute.mockImplementation(async (args?: { metric?: string }) => {
|
||||
if (args?.metric === "READINESS") return ok({ score: 64, band: "yellow" });
|
||||
if (args?.metric === "RECOVERY_SCORE") return ok({ score: 64, band: "yellow" });
|
||||
if (args?.metric === "READINESS")
|
||||
return ok({ score: 64, band: "yellow" });
|
||||
if (args?.metric === "RECOVERY_SCORE")
|
||||
return ok({ score: 64, band: "yellow" });
|
||||
return insufficient;
|
||||
});
|
||||
const block = await buildDerivedSnapshotBlock("u1", PROFILE, NOW);
|
||||
@@ -103,8 +124,10 @@ describe("buildDerivedSnapshotBlock", () => {
|
||||
// source-aware drop keeps it because a WHOOP-native row exists.
|
||||
setWhoopNativePresent(true);
|
||||
compute.mockImplementation(async (args?: { metric?: string }) => {
|
||||
if (args?.metric === "READINESS") return ok({ score: 64, band: "yellow" });
|
||||
if (args?.metric === "RECOVERY_SCORE") return ok({ score: 64, band: "yellow" });
|
||||
if (args?.metric === "READINESS")
|
||||
return ok({ score: 64, band: "yellow" });
|
||||
if (args?.metric === "RECOVERY_SCORE")
|
||||
return ok({ score: 64, band: "yellow" });
|
||||
return insufficient;
|
||||
});
|
||||
const block = await buildDerivedSnapshotBlock("u1", PROFILE, NOW);
|
||||
@@ -115,8 +138,10 @@ describe("buildDerivedSnapshotBlock", () => {
|
||||
it("keeps RECOVERY_SCORE when a WHOOP-native value differs from READINESS", async () => {
|
||||
setWhoopNativePresent(true);
|
||||
compute.mockImplementation(async (args?: { metric?: string }) => {
|
||||
if (args?.metric === "READINESS") return ok({ score: 64, band: "yellow" });
|
||||
if (args?.metric === "RECOVERY_SCORE") return ok({ score: 80, band: "green" });
|
||||
if (args?.metric === "READINESS")
|
||||
return ok({ score: 64, band: "yellow" });
|
||||
if (args?.metric === "RECOVERY_SCORE")
|
||||
return ok({ score: 80, band: "green" });
|
||||
return insufficient;
|
||||
});
|
||||
const block = await buildDerivedSnapshotBlock("u1", PROFILE, NOW);
|
||||
@@ -127,12 +152,18 @@ describe("buildDerivedSnapshotBlock", () => {
|
||||
it("isolates a per-metric compute failure", async () => {
|
||||
compute.mockImplementation(async (args?: { metric?: string }) => {
|
||||
if (args?.metric === "READINESS") throw new Error("boom");
|
||||
if (args?.metric === "SLEEP_SCORE") return ok({ score: 80, band: "green" });
|
||||
if (args?.metric === "SLEEP_SCORE")
|
||||
return ok({ score: 80, band: "green" });
|
||||
return insufficient;
|
||||
});
|
||||
const block = await buildDerivedSnapshotBlock("u1", PROFILE, NOW);
|
||||
expect(block).toEqual({
|
||||
SLEEP_SCORE: { value: 80, band: "green", confidence: 88, historyDays: 14 },
|
||||
SLEEP_SCORE: {
|
||||
value: 80,
|
||||
band: "green",
|
||||
confidence: 88,
|
||||
historyDays: 14,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -59,15 +59,22 @@ describe("buildCoachFactsBlock", () => {
|
||||
});
|
||||
|
||||
expect(block).not.toBeNull();
|
||||
expect(block!.facts.map((f) => f.text)).toEqual(["newer high", "older high", "low"]);
|
||||
expect(block!.facts.map((f) => f.text)).toEqual([
|
||||
"newer high",
|
||||
"older high",
|
||||
"low",
|
||||
]);
|
||||
});
|
||||
|
||||
it("takes only the top N", async () => {
|
||||
const seed: SeedRow[] = Array.from({ length: FACTS_INJECT_TOP_N + 4 }, (_, i) => ({
|
||||
text: `fact ${i}`,
|
||||
confidence: 100 - i,
|
||||
updatedAt: new Date(2026, 0, 1 + i),
|
||||
}));
|
||||
const seed: SeedRow[] = Array.from(
|
||||
{ length: FACTS_INJECT_TOP_N + 4 },
|
||||
(_, i) => ({
|
||||
text: `fact ${i}`,
|
||||
confidence: 100 - i,
|
||||
updatedAt: new Date(2026, 0, 1 + i),
|
||||
}),
|
||||
);
|
||||
const { prisma } = makeFakePrisma(seed);
|
||||
|
||||
const block = await buildCoachFactsBlock("user-1", {
|
||||
@@ -92,8 +99,18 @@ describe("buildCoachFactsBlock", () => {
|
||||
it("skips undecryptable rows fault-isolated (never throws)", async () => {
|
||||
// One row whose codec throws on decrypt; the helper must skip it.
|
||||
const findMany = vi.fn(async () => [
|
||||
{ factEncrypted: bytes("good fact"), category: "goal", confidence: 80, updatedAt: new Date() },
|
||||
{ factEncrypted: bytes("__throw__"), category: "goal", confidence: 70, updatedAt: new Date() },
|
||||
{
|
||||
factEncrypted: bytes("good fact"),
|
||||
category: "goal",
|
||||
confidence: 80,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
factEncrypted: bytes("__throw__"),
|
||||
category: "goal",
|
||||
confidence: 70,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
]);
|
||||
// Re-point the codec mock to throw for the sentinel payload.
|
||||
const codec = await import("../bytes-codec");
|
||||
|
||||
@@ -85,8 +85,10 @@ describe("extractDeterministicFacts", () => {
|
||||
|
||||
it("matches German intolerance statements", () => {
|
||||
expect(
|
||||
extractDeterministicFacts("ich habe eine Laktoseunverträglichkeit", "de")[0]
|
||||
?.fact,
|
||||
extractDeterministicFacts(
|
||||
"ich habe eine Laktoseunverträglichkeit",
|
||||
"de",
|
||||
)[0]?.fact,
|
||||
).toContain("Laktose");
|
||||
expect(
|
||||
extractDeterministicFacts("ich bin laktoseintolerant", "de")[0]?.fact,
|
||||
@@ -139,7 +141,10 @@ describe("extractDeterministicFacts", () => {
|
||||
[],
|
||||
);
|
||||
expect(
|
||||
extractDeterministicFacts("meine Schwester hat eine Katzenhaarallergie", "de"),
|
||||
extractDeterministicFacts(
|
||||
"meine Schwester hat eine Katzenhaarallergie",
|
||||
"de",
|
||||
),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
extractDeterministicFacts("what is an allergy exactly?", "en"),
|
||||
|
||||
@@ -47,7 +47,12 @@ function bytes(s: string): Uint8Array {
|
||||
}
|
||||
|
||||
function makeFakePrisma(opts: {
|
||||
activeFacts?: Array<{ id?: string; text: string; category?: string; confidence?: number }>;
|
||||
activeFacts?: Array<{
|
||||
id?: string;
|
||||
text: string;
|
||||
category?: string;
|
||||
confidence?: number;
|
||||
}>;
|
||||
turns?: Array<{ role: string; content: string }>;
|
||||
conversationExists?: boolean;
|
||||
}) {
|
||||
@@ -87,7 +92,13 @@ function makeFakePrisma(opts: {
|
||||
}
|
||||
|
||||
function ok(content: string): StatusProviderResult {
|
||||
return { kind: "ok", content, providerType: "mock", model: "m", tokensUsed: 1 };
|
||||
return {
|
||||
kind: "ok",
|
||||
content,
|
||||
providerType: "mock",
|
||||
model: "m",
|
||||
tokensUsed: 1,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -97,12 +108,21 @@ beforeEach(() => {
|
||||
describe("extractAndStoreFacts", () => {
|
||||
it("(a) parses durable facts and persists them field-by-field", async () => {
|
||||
const { prisma, createCalls } = makeFakePrisma({
|
||||
turns: [{ role: "user", content: "I prefer morning workouts and I'm vegetarian." }],
|
||||
turns: [
|
||||
{
|
||||
role: "user",
|
||||
content: "I prefer morning workouts and I'm vegetarian.",
|
||||
},
|
||||
],
|
||||
});
|
||||
const runCompletion = vi.fn(async () =>
|
||||
ok(
|
||||
JSON.stringify([
|
||||
{ category: "preference", fact: "Prefers morning workouts", confidence: 90 },
|
||||
{
|
||||
category: "preference",
|
||||
fact: "Prefers morning workouts",
|
||||
confidence: 90,
|
||||
},
|
||||
{ category: "preference", fact: "Is vegetarian", confidence: 80 },
|
||||
]),
|
||||
),
|
||||
@@ -120,7 +140,13 @@ describe("extractAndStoreFacts", () => {
|
||||
// Field-by-field — exact key set, no spread of the parsed object.
|
||||
for (const data of createCalls) {
|
||||
expect(Object.keys(data).sort()).toEqual(
|
||||
["category", "confidence", "factEncrypted", "sourceConversationId", "userId"].sort(),
|
||||
[
|
||||
"category",
|
||||
"confidence",
|
||||
"factEncrypted",
|
||||
"sourceConversationId",
|
||||
"userId",
|
||||
].sort(),
|
||||
);
|
||||
expect(data.userId).toBe("user-1");
|
||||
expect(data.sourceConversationId).toBe("conv-1");
|
||||
@@ -128,7 +154,9 @@ describe("extractAndStoreFacts", () => {
|
||||
}
|
||||
// Annotation carries counts/ids only.
|
||||
const extracted = annotateMock.mock.calls.find(
|
||||
(c) => (c[0] as { action?: { name?: string } }).action?.name === "coach.facts.extracted",
|
||||
(c) =>
|
||||
(c[0] as { action?: { name?: string } }).action?.name ===
|
||||
"coach.facts.extracted",
|
||||
);
|
||||
expect(extracted).toBeTruthy();
|
||||
expect((extracted![0] as { meta: Record<string, unknown> }).meta).toEqual({
|
||||
@@ -181,7 +209,9 @@ describe("extractAndStoreFacts", () => {
|
||||
expect(res).toEqual({ status: "none", count: 0 });
|
||||
expect(createCalls).toHaveLength(0);
|
||||
const failed = annotateMock.mock.calls.find(
|
||||
(c) => (c[0] as { action?: { name?: string } }).action?.name === "coach.facts.parse_failed",
|
||||
(c) =>
|
||||
(c[0] as { action?: { name?: string } }).action?.name ===
|
||||
"coach.facts.parse_failed",
|
||||
);
|
||||
expect(failed).toBeTruthy();
|
||||
expect((failed![0] as { meta: Record<string, unknown> }).meta).toEqual({
|
||||
@@ -197,8 +227,16 @@ describe("extractAndStoreFacts", () => {
|
||||
const runCompletion = vi.fn(async () =>
|
||||
ok(
|
||||
JSON.stringify([
|
||||
{ category: "preference", fact: "prefers morning workouts", confidence: 90 }, // dup
|
||||
{ category: "goal", fact: "Wants to run a 10k in autumn", confidence: 85 }, // new
|
||||
{
|
||||
category: "preference",
|
||||
fact: "prefers morning workouts",
|
||||
confidence: 90,
|
||||
}, // dup
|
||||
{
|
||||
category: "goal",
|
||||
fact: "Wants to run a 10k in autumn",
|
||||
confidence: 85,
|
||||
}, // new
|
||||
]),
|
||||
),
|
||||
);
|
||||
@@ -229,7 +267,11 @@ describe("extractAndStoreFacts", () => {
|
||||
ok(
|
||||
JSON.stringify([
|
||||
{ category: "goal", fact: "low confidence newcomer", confidence: 20 }, // ≤30 → skip
|
||||
{ category: "goal", fact: "high confidence newcomer", confidence: 95 }, // >30 → store
|
||||
{
|
||||
category: "goal",
|
||||
fact: "high confidence newcomer",
|
||||
confidence: 95,
|
||||
}, // >30 → store
|
||||
]),
|
||||
),
|
||||
);
|
||||
@@ -251,7 +293,9 @@ describe("extractAndStoreFacts", () => {
|
||||
const { prisma, createCalls } = makeFakePrisma({
|
||||
turns: [{ role: "user", content: "talk" }],
|
||||
});
|
||||
const runCompletion = vi.fn(async () => ({ kind }) as StatusProviderResult);
|
||||
const runCompletion = vi.fn(
|
||||
async () => ({ kind }) as StatusProviderResult,
|
||||
);
|
||||
const res = await extractAndStoreFacts("conv-1", "user-1", {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
prisma: prisma as any,
|
||||
|
||||
@@ -93,7 +93,11 @@ describe("buildCoachMemoryBlock", () => {
|
||||
expect(block?.priorNarrative?.headline).toContain("Ruhepuls");
|
||||
expect(block?.priorNarrative?.drivers).toEqual([]);
|
||||
expect(block?.trendMemory).toEqual({
|
||||
HEART_RATE: { priorBand: "in", currentBand: "above", priorPeriod: "month" },
|
||||
HEART_RATE: {
|
||||
priorBand: "in",
|
||||
currentBand: "above",
|
||||
priorPeriod: "month",
|
||||
},
|
||||
WEIGHT: { priorBand: "in", currentBand: "in", priorPeriod: "month" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,8 +9,8 @@ const txCreate = {
|
||||
};
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
$transaction: vi.fn(
|
||||
async (cb: (tx: typeof txCreate) => Promise<unknown>) => cb(txCreate),
|
||||
$transaction: vi.fn(async (cb: (tx: typeof txCreate) => Promise<unknown>) =>
|
||||
cb(txCreate),
|
||||
),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -40,7 +40,9 @@ const BRAND_TOKENS = [
|
||||
|
||||
describe("buildReferenceGroundingBlock", () => {
|
||||
it("returns null when no metric is covered by the backbone", () => {
|
||||
expect(buildReferenceGroundingBlock({ metrics: [], hasDiabetes: false })).toBeNull();
|
||||
expect(
|
||||
buildReferenceGroundingBlock({ metrics: [], hasDiabetes: false }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("cites the ESH 2023 source for blood pressure and never the US/ESC line", () => {
|
||||
@@ -74,9 +76,7 @@ describe("buildReferenceGroundingBlock", () => {
|
||||
})!;
|
||||
expect(block).toContain("general guidance");
|
||||
expect(block).toContain("not a diagnosis");
|
||||
expect(block.toLowerCase()).toContain(
|
||||
"not personal medical advice",
|
||||
);
|
||||
expect(block.toLowerCase()).toContain("not personal medical advice");
|
||||
// The closing reminder line is present.
|
||||
expect(block).toMatch(/general guidance, not personal medical advice\.$/);
|
||||
});
|
||||
@@ -150,7 +150,9 @@ describe("buildReferenceGroundingBlock", () => {
|
||||
metrics: [{ metric: "PULSE_PRESSURE", value: 40 }],
|
||||
hasDiabetes: false,
|
||||
})!;
|
||||
const line = block.split("\n").find((l) => l.startsWith("- pulse pressure:"))!;
|
||||
const line = block
|
||||
.split("\n")
|
||||
.find((l) => l.startsWith("- pulse pressure:"))!;
|
||||
// The normal "Typical at rest" band (25–60), not the first "Narrow" band.
|
||||
expect(line).toContain("25–60 mmHg");
|
||||
expect(line).toContain("sits inside the general reference band");
|
||||
|
||||
@@ -33,9 +33,9 @@ vi.mock("@/lib/modules/gate", () => ({
|
||||
// with the same resolved map the tests drive via `resolveModuleMap`.
|
||||
isModuleEnabled: vi.fn(async (...args: [string, ModuleKey]) => {
|
||||
const key = args[1];
|
||||
const map = await (resolveModuleMap as unknown as () => Promise<
|
||||
Record<ModuleKey, boolean>
|
||||
>)();
|
||||
const map = await (
|
||||
resolveModuleMap as unknown as () => Promise<Record<ModuleKey, boolean>>
|
||||
)();
|
||||
return map[key] !== false;
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -44,9 +44,9 @@ vi.mock("@/lib/modules/gate", () => ({
|
||||
// with the same resolved map the tests drive via `resolveModuleMap`.
|
||||
isModuleEnabled: vi.fn(async (...args: [string, ModuleKey]) => {
|
||||
const key = args[1];
|
||||
const map = await (resolveModuleMap as unknown as () => Promise<
|
||||
Record<ModuleKey, boolean>
|
||||
>)();
|
||||
const map = await (
|
||||
resolveModuleMap as unknown as () => Promise<Record<ModuleKey, boolean>>
|
||||
)();
|
||||
return map[key] !== false;
|
||||
}),
|
||||
}));
|
||||
@@ -406,7 +406,10 @@ describe("buildCoachSnapshot — WHOOP-native day strain (v1.17.0)", () => {
|
||||
mockByType({
|
||||
HEART_RATE_VARIABILITY: [daysAgo(2, 64, "HEART_RATE_VARIABILITY")],
|
||||
// Ascending (oldest first), mirroring the `orderBy: measuredAt asc` read.
|
||||
DAY_STRAIN: [daysAgo(3, 11.8, "DAY_STRAIN"), daysAgo(1, 14.2, "DAY_STRAIN")],
|
||||
DAY_STRAIN: [
|
||||
daysAgo(3, 11.8, "DAY_STRAIN"),
|
||||
daysAgo(1, 14.2, "DAY_STRAIN"),
|
||||
],
|
||||
});
|
||||
|
||||
const out = await buildCoachSnapshot("user-1", {
|
||||
|
||||
@@ -46,9 +46,9 @@ vi.mock("@/lib/modules/gate", () => ({
|
||||
// cycle block (operator/user kill-switch parity) without a DB read.
|
||||
isModuleEnabled: vi.fn(async (...args: [string, ModuleKey]) => {
|
||||
const key = args[1];
|
||||
const map = await (resolveModuleMap as unknown as () => Promise<
|
||||
Record<ModuleKey, boolean>
|
||||
>)();
|
||||
const map = await (
|
||||
resolveModuleMap as unknown as () => Promise<Record<ModuleKey, boolean>>
|
||||
)();
|
||||
return map[key] !== false;
|
||||
}),
|
||||
}));
|
||||
@@ -319,12 +319,12 @@ describe("buildCoachSnapshot", () => {
|
||||
// The compliance query and the GLP-1 query share this mock; only the
|
||||
// compliance one (no `treatmentClass` filter) gets the fixture so the
|
||||
// GLP-1 block — which selects `doseChanges` etc. — sees no rows.
|
||||
prismaMock.medication.findMany.mockImplementation((args?: {
|
||||
where?: { treatmentClass?: string };
|
||||
}) => {
|
||||
if (args?.where?.treatmentClass === "GLP1") return Promise.resolve([]);
|
||||
return Promise.resolve([medication]);
|
||||
});
|
||||
prismaMock.medication.findMany.mockImplementation(
|
||||
(args?: { where?: { treatmentClass?: string } }) => {
|
||||
if (args?.where?.treatmentClass === "GLP1") return Promise.resolve([]);
|
||||
return Promise.resolve([medication]);
|
||||
},
|
||||
);
|
||||
prismaMock.user.findUnique.mockResolvedValue({
|
||||
coachPrefsJson: null,
|
||||
timezone: "Europe/Berlin",
|
||||
@@ -339,7 +339,11 @@ describe("buildCoachSnapshot", () => {
|
||||
// Independent authority over the same window the coach uses (30-day
|
||||
// default scope), same ledger context — the coach rate must equal it.
|
||||
const now = new Date();
|
||||
const ctx = buildComplianceMedicationContext(medication, null, "Europe/Berlin");
|
||||
const ctx = buildComplianceMedicationContext(
|
||||
medication,
|
||||
null,
|
||||
"Europe/Berlin",
|
||||
);
|
||||
const authority = calculateCompliance(
|
||||
intakeEvents,
|
||||
[schedule],
|
||||
@@ -672,9 +676,8 @@ describe("buildCoachSnapshot", () => {
|
||||
recovery: false,
|
||||
});
|
||||
const { buildDerivedSnapshotBlock } = await import("../derived-snapshot");
|
||||
const { buildTrajectorySnapshotBlock } = await import(
|
||||
"../trajectory-snapshot"
|
||||
);
|
||||
const { buildTrajectorySnapshotBlock } =
|
||||
await import("../trajectory-snapshot");
|
||||
|
||||
prismaMock.measurement.findMany.mockResolvedValue([]);
|
||||
featuresMock.mockResolvedValue({
|
||||
@@ -850,8 +853,7 @@ describe("Coach snapshot — free-text fields wrap through sanitizeForPrompt (L-
|
||||
if (violations.length > 0) {
|
||||
const formatted = violations
|
||||
.map(
|
||||
(v) =>
|
||||
` ${filePath}:${v.line} — \`${v.fieldName}\`: ${v.snippet}`,
|
||||
(v) => ` ${filePath}:${v.line} — \`${v.fieldName}\`: ${v.snippet}`,
|
||||
)
|
||||
.join("\n");
|
||||
throw new Error(
|
||||
|
||||
@@ -42,7 +42,10 @@ describe("decideFromPrefs", () => {
|
||||
|
||||
it("suppresses a dismissed cadence (dismissal memory)", () => {
|
||||
const d = decideFromPrefs(
|
||||
{ ...DEFAULT_REMINDER_SUGGESTION_PREFS, dismissedCadences: ["weight_daily"] },
|
||||
{
|
||||
...DEFAULT_REMINDER_SUGGESTION_PREFS,
|
||||
dismissedCadences: ["weight_daily"],
|
||||
},
|
||||
"weight_daily",
|
||||
NOW,
|
||||
);
|
||||
@@ -131,8 +134,11 @@ describe("gateSuggestion", () => {
|
||||
now: NOW,
|
||||
});
|
||||
expect(
|
||||
(prisma as unknown as { measurementReminder: { findFirst: ReturnType<typeof vi.fn> } })
|
||||
.measurementReminder.findFirst,
|
||||
(
|
||||
prisma as unknown as {
|
||||
measurementReminder: { findFirst: ReturnType<typeof vi.fn> };
|
||||
}
|
||||
).measurementReminder.findFirst,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -61,8 +61,7 @@ describe("parseSuggestReminder", () => {
|
||||
});
|
||||
|
||||
it("flags malformed and strips the block on an unknown cadence id", () => {
|
||||
const raw =
|
||||
"text\n---SUGGEST-REMINDER---\ncadence: bp_hourly\n---END---";
|
||||
const raw = "text\n---SUGGEST-REMINDER---\ncadence: bp_hourly\n---END---";
|
||||
const r = parseSuggestReminder(raw);
|
||||
expect(r.cadence).toBeNull();
|
||||
expect(r.malformed).toBe(true);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/insights/derived", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/insights/derived")>();
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/lib/insights/derived")>();
|
||||
return { ...actual, computeTrajectory: vi.fn() };
|
||||
});
|
||||
|
||||
@@ -26,20 +27,52 @@ function okTrajectory(type: string) {
|
||||
sampleDays: 20,
|
||||
lastValue: 61.44,
|
||||
projection: [
|
||||
{ dayOffset: 1, date: "2026-06-03", projected: 61.5, bandLow: 60.1, bandHigh: 62.9 },
|
||||
{ dayOffset: 14, date: "2026-06-16", projected: 63.16, bandLow: 60.02, bandHigh: 66.34 },
|
||||
{
|
||||
dayOffset: 1,
|
||||
date: "2026-06-03",
|
||||
projected: 61.5,
|
||||
bandLow: 60.1,
|
||||
bandHigh: 62.9,
|
||||
},
|
||||
{
|
||||
dayOffset: 14,
|
||||
date: "2026-06-16",
|
||||
projected: 63.16,
|
||||
bandLow: 60.02,
|
||||
bandHigh: 66.34,
|
||||
},
|
||||
],
|
||||
method: "ols" as const,
|
||||
},
|
||||
coverage: { requiredInputs: 1, presentInputs: 1, historyDays: 20, missing: [] },
|
||||
coverage: {
|
||||
requiredInputs: 1,
|
||||
presentInputs: 1,
|
||||
historyDays: 20,
|
||||
missing: [],
|
||||
},
|
||||
confidence: { score: 71, band: "moderate" as const },
|
||||
provenance: { inputs: [type], source: "DAY" as const, windowDays: 30, computedAt: "x" },
|
||||
provenance: {
|
||||
inputs: [type],
|
||||
source: "DAY" as const,
|
||||
windowDays: 30,
|
||||
computedAt: "x",
|
||||
},
|
||||
};
|
||||
}
|
||||
const insufficient = {
|
||||
status: "insufficient" as const,
|
||||
coverage: { requiredInputs: 1, presentInputs: 0, historyDays: 4, missing: [] },
|
||||
provenance: { inputs: [], source: "none" as const, windowDays: 30, computedAt: "x" },
|
||||
coverage: {
|
||||
requiredInputs: 1,
|
||||
presentInputs: 0,
|
||||
historyDays: 4,
|
||||
missing: [],
|
||||
},
|
||||
provenance: {
|
||||
inputs: [],
|
||||
source: "none" as const,
|
||||
windowDays: 30,
|
||||
computedAt: "x",
|
||||
},
|
||||
reason: "insufficient_fit_for_projection",
|
||||
};
|
||||
|
||||
|
||||
@@ -27,10 +27,7 @@
|
||||
* Server-only — reads `@/lib/db`.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
decryptFromBytes,
|
||||
encryptToBytes,
|
||||
} from "@/lib/ai/coach/bytes-codec";
|
||||
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import {
|
||||
SELF_REPORT_FENCE_START,
|
||||
SELF_REPORT_FENCE_END,
|
||||
@@ -87,11 +84,15 @@ export async function getSelfContextForUser(
|
||||
coachFocus: decryptOrNull(row?.coachFocusEncrypted ?? null),
|
||||
};
|
||||
} catch {
|
||||
return { aboutMe: null, conditions: null, allergies: null, coachFocus: null };
|
||||
return {
|
||||
aboutMe: null,
|
||||
conditions: null,
|
||||
allergies: null,
|
||||
coachFocus: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** Whole years between `dateOfBirth` and `now`; null when unknown. */
|
||||
export function deriveAgeYears(
|
||||
dateOfBirth: Date | null,
|
||||
@@ -142,9 +143,7 @@ export function composeSelfContextText(
|
||||
}
|
||||
}
|
||||
if (facts.length > 0) {
|
||||
lines.push(
|
||||
(de ? "Profil: " : "Profile: ") + facts.join(" · "),
|
||||
);
|
||||
lines.push((de ? "Profil: " : "Profile: ") + facts.join(" · "));
|
||||
}
|
||||
|
||||
if (ctx.conditions) {
|
||||
|
||||
@@ -84,13 +84,13 @@ export function buildSummaryUserPrompt(
|
||||
foldedTurns: SummaryFoldTurn[],
|
||||
locale: "de" | "en" = "en",
|
||||
): string {
|
||||
const priorLabel = locale === "de" ? "FRÜHERE ZUSAMMENFASSUNG" : "PRIOR SUMMARY";
|
||||
const priorLabel =
|
||||
locale === "de" ? "FRÜHERE ZUSAMMENFASSUNG" : "PRIOR SUMMARY";
|
||||
const turnsLabel = locale === "de" ? "NEUE WORTWECHSEL" : "NEW TURNS";
|
||||
const none = locale === "de" ? "(keine)" : "(none)";
|
||||
|
||||
const priorBlock = priorSummary && priorSummary.trim().length > 0
|
||||
? priorSummary.trim()
|
||||
: none;
|
||||
const priorBlock =
|
||||
priorSummary && priorSummary.trim().length > 0 ? priorSummary.trim() : none;
|
||||
|
||||
const turnsBlock = foldedTurns
|
||||
.map((t) => `${t.role}: ${t.content}`)
|
||||
@@ -182,9 +182,7 @@ export async function refreshConversationSummary(
|
||||
// fail-closed like every other decrypt in the Coach stack.
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{ role: m.role === "assistant" ? "assistant" : "user", content },
|
||||
];
|
||||
return [{ role: m.role === "assistant" ? "assistant" : "user", content }];
|
||||
});
|
||||
|
||||
// 5. Run the user's provider chain. Best-effort: non-ok leaves the old
|
||||
|
||||
@@ -21,10 +21,7 @@
|
||||
* day" claim.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
predictCycle,
|
||||
type NightlyTempInput,
|
||||
} from "@/lib/cycle";
|
||||
import { predictCycle, type NightlyTempInput } from "@/lib/cycle";
|
||||
import { LUTEAL_DEFAULT, type CyclePhase } from "@/lib/cycle/types";
|
||||
import {
|
||||
phaseForDate,
|
||||
|
||||
@@ -68,11 +68,17 @@ function summariseValue(
|
||||
case "STRESS_SCORE":
|
||||
case "STRAIN_SCORE":
|
||||
return typeof v.score === "number"
|
||||
? { value: v.score, band: typeof v.band === "string" ? v.band : undefined }
|
||||
? {
|
||||
value: v.score,
|
||||
band: typeof v.band === "string" ? v.band : undefined,
|
||||
}
|
||||
: null;
|
||||
case "HRV_BALANCE":
|
||||
return typeof v.recentAvg === "number"
|
||||
? { value: Math.round(v.recentAvg), band: typeof v.band === "string" ? v.band : undefined }
|
||||
? {
|
||||
value: Math.round(v.recentAvg),
|
||||
band: typeof v.band === "string" ? v.band : undefined,
|
||||
}
|
||||
: null;
|
||||
case "FITNESS_AGE":
|
||||
return typeof v.vo2Max === "number"
|
||||
@@ -119,7 +125,10 @@ export async function buildDerivedSnapshotBlock(
|
||||
const computed = await Promise.all(
|
||||
SNAPSHOT_METRICS.map(async (metric) => {
|
||||
try {
|
||||
return { metric, derived: await computeDerivedMetric({ metric, userId, profile, now }) };
|
||||
return {
|
||||
metric,
|
||||
derived: await computeDerivedMetric({ metric, userId, profile, now }),
|
||||
};
|
||||
} catch {
|
||||
return { metric, derived: null };
|
||||
}
|
||||
|
||||
+38
-14
@@ -89,11 +89,7 @@ function extractionSystemPrompt(locale: string | undefined): string {
|
||||
|
||||
const rawFactSchema = z.object({
|
||||
category: z.enum(COACH_FACT_CATEGORIES),
|
||||
fact: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(FACT_MAX_CHARS),
|
||||
fact: z.string().trim().min(1).max(FACT_MAX_CHARS),
|
||||
confidence: z.coerce.number().int().min(0).max(100),
|
||||
});
|
||||
|
||||
@@ -110,7 +106,9 @@ interface ParsedFact {
|
||||
* top-level JSON is unparseable (the caller then annotates `parse_failed`);
|
||||
* individual malformed items are dropped silently, not fatal.
|
||||
*/
|
||||
function parseFacts(content: string): { facts: ParsedFact[]; dropped: number } | null {
|
||||
function parseFacts(
|
||||
content: string,
|
||||
): { facts: ParsedFact[]; dropped: number } | null {
|
||||
let json: unknown;
|
||||
try {
|
||||
json = JSON.parse(content.trim());
|
||||
@@ -129,7 +127,10 @@ function parseFacts(content: string): { facts: ParsedFact[]; dropped: number } |
|
||||
facts.push({
|
||||
category: parsed.data.category,
|
||||
fact: parsed.data.fact,
|
||||
confidence: Math.max(0, Math.min(100, Math.round(parsed.data.confidence))),
|
||||
confidence: Math.max(
|
||||
0,
|
||||
Math.min(100, Math.round(parsed.data.confidence)),
|
||||
),
|
||||
});
|
||||
} else {
|
||||
dropped += 1;
|
||||
@@ -169,7 +170,8 @@ function isNearDuplicate(candidate: string, existing: string[]): boolean {
|
||||
const cNorm = candidate.trim().toLowerCase();
|
||||
for (const ex of existing) {
|
||||
if (ex.trim().toLowerCase() === cNorm) return true;
|
||||
if (tokenOverlap(cTokens, tokenSet(ex)) >= DEDUP_OVERLAP_THRESHOLD) return true;
|
||||
if (tokenOverlap(cTokens, tokenSet(ex)) >= DEDUP_OVERLAP_THRESHOLD)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -197,17 +199,29 @@ interface ActiveFactRow {
|
||||
async function loadActiveFacts(
|
||||
db: PrismaLike,
|
||||
userId: string,
|
||||
): Promise<Array<{ id: string; text: string; category: string; confidence: number }>> {
|
||||
): Promise<
|
||||
Array<{ id: string; text: string; category: string; confidence: number }>
|
||||
> {
|
||||
const rows = (await db.coachFact.findMany({
|
||||
where: { userId, deletedAt: null },
|
||||
select: { id: true, factEncrypted: true, category: true, confidence: true },
|
||||
})) as ActiveFactRow[];
|
||||
|
||||
const out: Array<{ id: string; text: string; category: string; confidence: number }> = [];
|
||||
const out: Array<{
|
||||
id: string;
|
||||
text: string;
|
||||
category: string;
|
||||
confidence: number;
|
||||
}> = [];
|
||||
for (const r of rows) {
|
||||
const text = decryptOrNull(r.factEncrypted);
|
||||
if (text === null) continue;
|
||||
out.push({ id: r.id, text, category: r.category, confidence: r.confidence });
|
||||
out.push({
|
||||
id: r.id,
|
||||
text,
|
||||
category: r.category,
|
||||
confidence: r.confidence,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -341,7 +355,9 @@ export async function extractAndStoreFacts(
|
||||
|
||||
const toStore: ParsedFact[] = [];
|
||||
// Highest-confidence candidates first so a tight cap admits the best.
|
||||
for (const cand of [...accepted].sort((a, b) => b.confidence - a.confidence)) {
|
||||
for (const cand of [...accepted].sort(
|
||||
(a, b) => b.confidence - a.confidence,
|
||||
)) {
|
||||
if (remainingCapacity > 0) {
|
||||
toStore.push(cand);
|
||||
remainingCapacity -= 1;
|
||||
@@ -529,7 +545,10 @@ const DETERMINISTIC_PATTERNS: DeterministicPattern[] = [
|
||||
];
|
||||
|
||||
function cleanCapture(raw: string): string | null {
|
||||
const cleaned = raw.replace(/\s+/g, " ").replace(/[.,;:!?]+$/u, "").trim();
|
||||
const cleaned = raw
|
||||
.replace(/\s+/g, " ")
|
||||
.replace(/[.,;:!?]+$/u, "")
|
||||
.trim();
|
||||
if (cleaned.length < 2 || cleaned.length > 60) return null;
|
||||
return cleaned;
|
||||
}
|
||||
@@ -653,7 +672,12 @@ export async function buildCoachFactsBlock(
|
||||
const rows = (await db.coachFact.findMany({
|
||||
where: { userId, deletedAt: null },
|
||||
orderBy: [{ confidence: "desc" }, { updatedAt: "desc" }],
|
||||
select: { factEncrypted: true, category: true, confidence: true, updatedAt: true },
|
||||
select: {
|
||||
factEncrypted: true,
|
||||
category: true,
|
||||
confidence: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
})) as RankFactRow[];
|
||||
|
||||
const facts: Array<{ category: string; text: string }> = [];
|
||||
|
||||
@@ -70,7 +70,6 @@ function deriveDrugNames(raw: string): { display: string; generic: string } {
|
||||
return { display, generic };
|
||||
}
|
||||
|
||||
|
||||
function parseDaysOfWeek(raw: string | null): number[] {
|
||||
if (!raw) return [];
|
||||
return raw
|
||||
@@ -437,4 +436,3 @@ export async function buildGlp1SnapshotBlock(
|
||||
medications,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -72,7 +72,12 @@ export async function buildIllnessSnapshotBlock(
|
||||
|
||||
const [activeRows, resolvedRows] = await Promise.all([
|
||||
prisma.illnessEpisode.findMany({
|
||||
where: { userId, deletedAt: null, resolvedAt: null, onsetAt: { lte: now } },
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
resolvedAt: null,
|
||||
onsetAt: { lte: now },
|
||||
},
|
||||
orderBy: { onsetAt: "asc" },
|
||||
select: { label: true, type: true, lifecycle: true, onsetAt: true },
|
||||
}),
|
||||
|
||||
@@ -78,10 +78,7 @@ export const medicationExtractionSchema = z
|
||||
intervalMonths: z.number().int().min(1).max(12).optional(),
|
||||
dayOfMonth: z.number().int().min(1).max(31).optional(),
|
||||
rollingIntervalDays: z.number().int().min(1).max(365).optional(),
|
||||
timesOfDay: z
|
||||
.array(z.string().regex(HH_MM_RE, "HH:MM"))
|
||||
.max(12)
|
||||
.optional(),
|
||||
timesOfDay: z.array(z.string().regex(HH_MM_RE, "HH:MM")).max(12).optional(),
|
||||
startsOn: z.string().regex(ISO_DATE_RE, "YYYY-MM-DD").optional(),
|
||||
endsOn: z.string().regex(ISO_DATE_RE, "YYYY-MM-DD").optional(),
|
||||
oneShot: z.boolean().optional(),
|
||||
@@ -120,7 +117,7 @@ export function buildMedicationExtractionSystemPrompt(): string {
|
||||
"Every field is optional — omit a field rather than guess. Keys:",
|
||||
"",
|
||||
" name — medication brand or active substance, verbatim",
|
||||
" dose — numeric portion only, as a string: \"5\", \"7.5\"",
|
||||
' dose — numeric portion only, as a string: "5", "7.5"',
|
||||
" doseUnit — one of: mg | ml | iu | tablets | drops | puffs | sprays",
|
||||
" cadenceKind — one of: daily | weekdays | everyNWeeks |",
|
||||
" monthly | everyNMonths | yearly | rolling |",
|
||||
@@ -130,9 +127,9 @@ export function buildMedicationExtractionSystemPrompt(): string {
|
||||
" intervalWeeks — 1..52, used with everyNWeeks",
|
||||
" intervalMonths — 1..12, used with everyNMonths",
|
||||
" dayOfMonth — 1..31, used with monthly / everyNMonths",
|
||||
" rollingIntervalDays — 1..365, used with rolling (\"every N days",
|
||||
" from the last intake\")",
|
||||
" timesOfDay — array of \"HH:MM\" wall-clock strings (24h)",
|
||||
' rollingIntervalDays — 1..365, used with rolling ("every N days',
|
||||
' from the last intake")',
|
||||
' timesOfDay — array of "HH:MM" wall-clock strings (24h)',
|
||||
" startsOn — ISO YYYY-MM-DD, course start date",
|
||||
" endsOn — ISO YYYY-MM-DD, course end date",
|
||||
" oneShot — true when the user describes a single-time",
|
||||
@@ -147,26 +144,26 @@ export function buildMedicationExtractionSystemPrompt(): string {
|
||||
" not state a numeric dose, omit `dose` and `doseUnit`.",
|
||||
"",
|
||||
" 2. Map vague time-of-day words to canonical 24h slots:",
|
||||
" morning → \"08:00\", noon / midday → \"12:00\",",
|
||||
" afternoon → \"14:00\", evening → \"18:00\", night → \"22:00\".",
|
||||
' morning → "08:00", noon / midday → "12:00",',
|
||||
' afternoon → "14:00", evening → "18:00", night → "22:00".',
|
||||
" Multiple times in one description → emit each as its own entry.",
|
||||
"",
|
||||
" 3. \"Every day\" / \"daily\" / \"once a day\" → `cadenceKind:",
|
||||
" \"daily\"`. \"Twice a day\" / \"3 times a day\" → still",
|
||||
' 3. "Every day" / "daily" / "once a day" → `cadenceKind:',
|
||||
' "daily"`. "Twice a day" / "3 times a day" → still',
|
||||
" `daily` with the right `timesOfDay[]` count; never invent a",
|
||||
" weekly cadence.",
|
||||
"",
|
||||
" 4. \"Every N days from my last injection / dose\" /",
|
||||
" \"7 days after the previous one\" → `rolling` with",
|
||||
' 4. "Every N days from my last injection / dose" /',
|
||||
' "7 days after the previous one" → `rolling` with',
|
||||
" `rollingIntervalDays = N`. This is distinct from",
|
||||
" `everyNWeeks` (calendar-anchored).",
|
||||
"",
|
||||
" 5. Single-shot wording (\"single flu shot\", \"one-off dose\",",
|
||||
" \"on October 15 only\") → `oneShot: true` and a `startsOn`",
|
||||
' 5. Single-shot wording ("single flu shot", "one-off dose",',
|
||||
' "on October 15 only") → `oneShot: true` and a `startsOn`',
|
||||
" date. Do not also emit `cadenceKind`.",
|
||||
"",
|
||||
" 6. Resolve relative dates against today's date supplied in the",
|
||||
" CONTEXT block. \"Next Monday\", \"tomorrow\", \"in 2 weeks\"",
|
||||
' CONTEXT block. "Next Monday", "tomorrow", "in 2 weeks"',
|
||||
" become absolute ISO dates.",
|
||||
"",
|
||||
" 7. If a field is ambiguous, leave it empty. The wizard prefers",
|
||||
@@ -206,9 +203,10 @@ export function buildMedicationExtractionUserPrompt(
|
||||
* payload without having to glue the two helpers together at every call
|
||||
* site.
|
||||
*/
|
||||
export function buildMedicationExtractionPrompt(
|
||||
args: BuildUserPromptArgs,
|
||||
): { systemPrompt: string; userPrompt: string } {
|
||||
export function buildMedicationExtractionPrompt(args: BuildUserPromptArgs): {
|
||||
systemPrompt: string;
|
||||
userPrompt: string;
|
||||
} {
|
||||
return {
|
||||
systemPrompt: buildMedicationExtractionSystemPrompt(),
|
||||
userPrompt: buildMedicationExtractionUserPrompt(args),
|
||||
|
||||
@@ -166,11 +166,7 @@ export async function buildCoachMemoryBlock(
|
||||
facts = undefined;
|
||||
}
|
||||
|
||||
if (
|
||||
!priorNarrative &&
|
||||
Object.keys(trendMemory).length === 0 &&
|
||||
!facts
|
||||
) {
|
||||
if (!priorNarrative && Object.keys(trendMemory).length === 0 && !facts) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -360,4 +360,3 @@ export async function deleteConversation(
|
||||
await prisma.coachConversation.delete({ where: { id: row.id } });
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -249,8 +249,8 @@ export function buildReferenceGroundingBlock(
|
||||
"These are published POPULATION reference bands for context only — they",
|
||||
"are not personal medical advice, not a diagnosis, and not a target set",
|
||||
"for this user. The user's own baseline always leads the read. Cite a",
|
||||
"band only to orient a reply (\"the general reference range is …\"); never",
|
||||
"tell the user they \"have\" or \"don't have\" a condition from a band.",
|
||||
'band only to orient a reply ("the general reference range is …"); never',
|
||||
'tell the user they "have" or "don\'t have" a condition from a band.',
|
||||
"Blood pressure follows the European ESH 2023 line; US (ACC/AHA) and",
|
||||
"ESC framings differ and are context, not the call. This block does not",
|
||||
"change any safety rule above — keep deferring dose / diagnosis / drug-",
|
||||
|
||||
@@ -21,10 +21,7 @@
|
||||
*
|
||||
* Server-only — reads `@/lib/db` through the provider resolver.
|
||||
*/
|
||||
import {
|
||||
hasAnyConfiguredProvider,
|
||||
resolveProvider,
|
||||
} from "@/lib/ai/provider";
|
||||
import { hasAnyConfiguredProvider, resolveProvider } from "@/lib/ai/provider";
|
||||
import {
|
||||
buildDateKey,
|
||||
getDailyTokenSpend,
|
||||
|
||||
+220
-53
@@ -201,13 +201,14 @@ const CORE_CLUSTERS: ReadonlySet<CoachDataCluster> = new Set<CoachDataCluster>([
|
||||
* a data domain. `labs` / `achievements` / `insights` / `doctorReport`
|
||||
* own no coach-snapshot data domain.
|
||||
*/
|
||||
const MODULE_EXCLUDED_SOURCES: Partial<Record<ModuleKey, CoachScopeSource[]>> = {
|
||||
mood: ["mood"],
|
||||
sleep: ["sleep"],
|
||||
glucose: ["glucose"],
|
||||
workouts: ["workouts"],
|
||||
recovery: ["hrv", "resting_hr", "vo2_max"],
|
||||
};
|
||||
const MODULE_EXCLUDED_SOURCES: Partial<Record<ModuleKey, CoachScopeSource[]>> =
|
||||
{
|
||||
mood: ["mood"],
|
||||
sleep: ["sleep"],
|
||||
glucose: ["glucose"],
|
||||
workouts: ["workouts"],
|
||||
recovery: ["hrv", "resting_hr", "vo2_max"],
|
||||
};
|
||||
|
||||
const WEEKDAY_KEYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const;
|
||||
|
||||
@@ -420,12 +421,18 @@ async function buildCoarseTimelineTail(
|
||||
return undefined;
|
||||
}
|
||||
const monthly = series.monthBand.map(
|
||||
(b) => [b.bucketStart, b.mean, b.min, b.max] as [string, number, number, number],
|
||||
(b) =>
|
||||
[b.bucketStart, b.mean, b.min, b.max] as [string, number, number, number],
|
||||
);
|
||||
const yearly = series.yearBand.map(
|
||||
(b) => [b.bucketStart, b.mean, b.min, b.max] as [string, number, number, number],
|
||||
(b) =>
|
||||
[b.bucketStart, b.mean, b.min, b.max] as [string, number, number, number],
|
||||
);
|
||||
if (monthly.length === 0 && yearly.length === 0 && series.anomalies.length === 0) {
|
||||
if (
|
||||
monthly.length === 0 &&
|
||||
yearly.length === 0 &&
|
||||
series.anomalies.length === 0
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
@@ -523,7 +530,10 @@ const snapshotCache = new Map<
|
||||
{ expiresAt: number; result: CoachSnapshotResult }
|
||||
>();
|
||||
|
||||
function snapshotCacheKey(userId: string, scope: CoachScope | undefined): string {
|
||||
function snapshotCacheKey(
|
||||
userId: string,
|
||||
scope: CoachScope | undefined,
|
||||
): string {
|
||||
// v1.7.0 — when the request pins an explicit source list, key on it.
|
||||
// Otherwise the source set is derived from the user's saved
|
||||
// `dataClusters`, which we don't read here (the cache must stay
|
||||
@@ -1523,42 +1533,202 @@ async function buildCoachSnapshotImpl(
|
||||
// so accounts without that data never see a void section.
|
||||
const valueBlocks: ValueBlock[] = [
|
||||
// ── cardio ──
|
||||
{ metric: "hrv", source: "hrv", snapshotKey: "heartRateVariability", type: "HEART_RATE_VARIABILITY" },
|
||||
{ metric: "resting_hr", source: "resting_hr", snapshotKey: "restingHeartRate", type: "RESTING_HEART_RATE" },
|
||||
{ metric: "walking_hr", source: "walking_hr", snapshotKey: "walkingHeartRateAverage", type: "WALKING_HEART_RATE_AVERAGE" },
|
||||
{ metric: "respiratory_rate", source: "respiratory_rate", snapshotKey: "respiratoryRate", type: "RESPIRATORY_RATE" },
|
||||
{ metric: "spo2", source: "spo2", snapshotKey: "oxygenSaturation", type: "OXYGEN_SATURATION" },
|
||||
{ metric: "pulse_wave_velocity", source: "pulse_wave_velocity", snapshotKey: "pulseWaveVelocity", type: "PULSE_WAVE_VELOCITY" },
|
||||
{ metric: "vascular_age", source: "vascular_age", snapshotKey: "vascularAge", type: "VASCULAR_AGE" },
|
||||
{
|
||||
metric: "hrv",
|
||||
source: "hrv",
|
||||
snapshotKey: "heartRateVariability",
|
||||
type: "HEART_RATE_VARIABILITY",
|
||||
},
|
||||
{
|
||||
metric: "resting_hr",
|
||||
source: "resting_hr",
|
||||
snapshotKey: "restingHeartRate",
|
||||
type: "RESTING_HEART_RATE",
|
||||
},
|
||||
{
|
||||
metric: "walking_hr",
|
||||
source: "walking_hr",
|
||||
snapshotKey: "walkingHeartRateAverage",
|
||||
type: "WALKING_HEART_RATE_AVERAGE",
|
||||
},
|
||||
{
|
||||
metric: "respiratory_rate",
|
||||
source: "respiratory_rate",
|
||||
snapshotKey: "respiratoryRate",
|
||||
type: "RESPIRATORY_RATE",
|
||||
},
|
||||
{
|
||||
metric: "spo2",
|
||||
source: "spo2",
|
||||
snapshotKey: "oxygenSaturation",
|
||||
type: "OXYGEN_SATURATION",
|
||||
},
|
||||
{
|
||||
metric: "pulse_wave_velocity",
|
||||
source: "pulse_wave_velocity",
|
||||
snapshotKey: "pulseWaveVelocity",
|
||||
type: "PULSE_WAVE_VELOCITY",
|
||||
},
|
||||
{
|
||||
metric: "vascular_age",
|
||||
source: "vascular_age",
|
||||
snapshotKey: "vascularAge",
|
||||
type: "VASCULAR_AGE",
|
||||
},
|
||||
// ── body composition ──
|
||||
{ metric: "body_fat", source: "body_fat", snapshotKey: "bodyFat", type: "BODY_FAT" },
|
||||
{ metric: "fat_mass", source: "fat_mass", snapshotKey: "fatMass", type: "FAT_MASS" },
|
||||
{ metric: "fat_free_mass", source: "fat_free_mass", snapshotKey: "fatFreeMass", type: "FAT_FREE_MASS" },
|
||||
{ metric: "muscle_mass", source: "muscle_mass", snapshotKey: "muscleMass", type: "MUSCLE_MASS" },
|
||||
{ metric: "lean_body_mass", source: "lean_body_mass", snapshotKey: "leanBodyMass", type: "LEAN_BODY_MASS" },
|
||||
{ metric: "bone_mass", source: "bone_mass", snapshotKey: "boneMass", type: "BONE_MASS" },
|
||||
{ metric: "total_body_water", source: "total_body_water", snapshotKey: "totalBodyWater", type: "TOTAL_BODY_WATER" },
|
||||
{ metric: "bmi", source: "bmi", snapshotKey: "bodyMassIndex", type: "BODY_MASS_INDEX" },
|
||||
{ metric: "visceral_fat", source: "visceral_fat", snapshotKey: "visceralFat", type: "VISCERAL_FAT" },
|
||||
{
|
||||
metric: "body_fat",
|
||||
source: "body_fat",
|
||||
snapshotKey: "bodyFat",
|
||||
type: "BODY_FAT",
|
||||
},
|
||||
{
|
||||
metric: "fat_mass",
|
||||
source: "fat_mass",
|
||||
snapshotKey: "fatMass",
|
||||
type: "FAT_MASS",
|
||||
},
|
||||
{
|
||||
metric: "fat_free_mass",
|
||||
source: "fat_free_mass",
|
||||
snapshotKey: "fatFreeMass",
|
||||
type: "FAT_FREE_MASS",
|
||||
},
|
||||
{
|
||||
metric: "muscle_mass",
|
||||
source: "muscle_mass",
|
||||
snapshotKey: "muscleMass",
|
||||
type: "MUSCLE_MASS",
|
||||
},
|
||||
{
|
||||
metric: "lean_body_mass",
|
||||
source: "lean_body_mass",
|
||||
snapshotKey: "leanBodyMass",
|
||||
type: "LEAN_BODY_MASS",
|
||||
},
|
||||
{
|
||||
metric: "bone_mass",
|
||||
source: "bone_mass",
|
||||
snapshotKey: "boneMass",
|
||||
type: "BONE_MASS",
|
||||
},
|
||||
{
|
||||
metric: "total_body_water",
|
||||
source: "total_body_water",
|
||||
snapshotKey: "totalBodyWater",
|
||||
type: "TOTAL_BODY_WATER",
|
||||
},
|
||||
{
|
||||
metric: "bmi",
|
||||
source: "bmi",
|
||||
snapshotKey: "bodyMassIndex",
|
||||
type: "BODY_MASS_INDEX",
|
||||
},
|
||||
{
|
||||
metric: "visceral_fat",
|
||||
source: "visceral_fat",
|
||||
snapshotKey: "visceralFat",
|
||||
type: "VISCERAL_FAT",
|
||||
},
|
||||
// ── activity ──
|
||||
{ metric: "steps", source: "steps", snapshotKey: "steps", type: "ACTIVITY_STEPS" },
|
||||
{ metric: "active_energy", source: "active_energy", snapshotKey: "activeEnergy", type: "ACTIVE_ENERGY_BURNED" },
|
||||
{ metric: "flights", source: "flights", snapshotKey: "flightsClimbed", type: "FLIGHTS_CLIMBED" },
|
||||
{ metric: "distance", source: "distance", snapshotKey: "walkingRunningDistance", type: "WALKING_RUNNING_DISTANCE" },
|
||||
{ metric: "vo2_max", source: "vo2_max", snapshotKey: "vo2Max", type: "VO2_MAX" },
|
||||
{
|
||||
metric: "steps",
|
||||
source: "steps",
|
||||
snapshotKey: "steps",
|
||||
type: "ACTIVITY_STEPS",
|
||||
},
|
||||
{
|
||||
metric: "active_energy",
|
||||
source: "active_energy",
|
||||
snapshotKey: "activeEnergy",
|
||||
type: "ACTIVE_ENERGY_BURNED",
|
||||
},
|
||||
{
|
||||
metric: "flights",
|
||||
source: "flights",
|
||||
snapshotKey: "flightsClimbed",
|
||||
type: "FLIGHTS_CLIMBED",
|
||||
},
|
||||
{
|
||||
metric: "distance",
|
||||
source: "distance",
|
||||
snapshotKey: "walkingRunningDistance",
|
||||
type: "WALKING_RUNNING_DISTANCE",
|
||||
},
|
||||
{
|
||||
metric: "vo2_max",
|
||||
source: "vo2_max",
|
||||
snapshotKey: "vo2Max",
|
||||
type: "VO2_MAX",
|
||||
},
|
||||
// ── mobility & gait ──
|
||||
{ metric: "walking_steadiness", source: "walking_steadiness", snapshotKey: "walkingSteadiness", type: "WALKING_STEADINESS" },
|
||||
{ metric: "walking_asymmetry", source: "walking_asymmetry", snapshotKey: "walkingAsymmetry", type: "WALKING_ASYMMETRY" },
|
||||
{ metric: "walking_double_support", source: "walking_double_support", snapshotKey: "walkingDoubleSupport", type: "WALKING_DOUBLE_SUPPORT" },
|
||||
{ metric: "walking_step_length", source: "walking_step_length", snapshotKey: "walkingStepLength", type: "WALKING_STEP_LENGTH" },
|
||||
{ metric: "walking_speed", source: "walking_speed", snapshotKey: "walkingSpeed", type: "WALKING_SPEED" },
|
||||
{
|
||||
metric: "walking_steadiness",
|
||||
source: "walking_steadiness",
|
||||
snapshotKey: "walkingSteadiness",
|
||||
type: "WALKING_STEADINESS",
|
||||
},
|
||||
{
|
||||
metric: "walking_asymmetry",
|
||||
source: "walking_asymmetry",
|
||||
snapshotKey: "walkingAsymmetry",
|
||||
type: "WALKING_ASYMMETRY",
|
||||
},
|
||||
{
|
||||
metric: "walking_double_support",
|
||||
source: "walking_double_support",
|
||||
snapshotKey: "walkingDoubleSupport",
|
||||
type: "WALKING_DOUBLE_SUPPORT",
|
||||
},
|
||||
{
|
||||
metric: "walking_step_length",
|
||||
source: "walking_step_length",
|
||||
snapshotKey: "walkingStepLength",
|
||||
type: "WALKING_STEP_LENGTH",
|
||||
},
|
||||
{
|
||||
metric: "walking_speed",
|
||||
source: "walking_speed",
|
||||
snapshotKey: "walkingSpeed",
|
||||
type: "WALKING_SPEED",
|
||||
},
|
||||
// ── environment / exposure ──
|
||||
{ metric: "audio_env", source: "audio_env", snapshotKey: "audioExposureEnvironment", type: "AUDIO_EXPOSURE_ENV" },
|
||||
{ metric: "audio_headphone", source: "audio_headphone", snapshotKey: "audioExposureHeadphone", type: "AUDIO_EXPOSURE_HEADPHONE" },
|
||||
{ metric: "audio_event", source: "audio_event", snapshotKey: "audioExposureEvent", type: "AUDIO_EXPOSURE_EVENT" },
|
||||
{ metric: "daylight", source: "daylight", snapshotKey: "timeInDaylight", type: "TIME_IN_DAYLIGHT" },
|
||||
{ metric: "skin_temp", source: "skin_temp", snapshotKey: "skinTemperature", type: "SKIN_TEMPERATURE" },
|
||||
{ metric: "body_temp", source: "body_temp", snapshotKey: "bodyTemperature", type: "BODY_TEMPERATURE" },
|
||||
{
|
||||
metric: "audio_env",
|
||||
source: "audio_env",
|
||||
snapshotKey: "audioExposureEnvironment",
|
||||
type: "AUDIO_EXPOSURE_ENV",
|
||||
},
|
||||
{
|
||||
metric: "audio_headphone",
|
||||
source: "audio_headphone",
|
||||
snapshotKey: "audioExposureHeadphone",
|
||||
type: "AUDIO_EXPOSURE_HEADPHONE",
|
||||
},
|
||||
{
|
||||
metric: "audio_event",
|
||||
source: "audio_event",
|
||||
snapshotKey: "audioExposureEvent",
|
||||
type: "AUDIO_EXPOSURE_EVENT",
|
||||
},
|
||||
{
|
||||
metric: "daylight",
|
||||
source: "daylight",
|
||||
snapshotKey: "timeInDaylight",
|
||||
type: "TIME_IN_DAYLIGHT",
|
||||
},
|
||||
{
|
||||
metric: "skin_temp",
|
||||
source: "skin_temp",
|
||||
snapshotKey: "skinTemperature",
|
||||
type: "SKIN_TEMPERATURE",
|
||||
},
|
||||
{
|
||||
metric: "body_temp",
|
||||
source: "body_temp",
|
||||
snapshotKey: "bodyTemperature",
|
||||
type: "BODY_TEMPERATURE",
|
||||
},
|
||||
];
|
||||
for (const block of valueBlocks) {
|
||||
if (!sources.has(block.source)) continue;
|
||||
@@ -1765,10 +1935,11 @@ async function buildCoachSnapshotImpl(
|
||||
// then convert each resulting figure ONCE (parity with the series
|
||||
// DTO + detail page + FHIR). mg/dL users stay byte-identical because
|
||||
// the conversion is skipped entirely.
|
||||
const recent = buildDailyValueRows(rows, recentCutoff, userTz).map((d) =>
|
||||
glucoseUnit === "mmol/L"
|
||||
? { ...d, value: convertGlucose(d.value, glucoseUnit) }
|
||||
: d,
|
||||
const recent = buildDailyValueRows(rows, recentCutoff, userTz).map(
|
||||
(d) =>
|
||||
glucoseUnit === "mmol/L"
|
||||
? { ...d, value: convertGlucose(d.value, glucoseUnit) }
|
||||
: d,
|
||||
);
|
||||
const weekly = bucketWeekly(
|
||||
rows.filter((r) => r.measuredAt < recentCutoff),
|
||||
@@ -1960,9 +2131,7 @@ async function buildCoachSnapshotImpl(
|
||||
const ctx = features.context;
|
||||
if (
|
||||
ctx &&
|
||||
(ctx.heightCm !== null ||
|
||||
ctx.ageYears !== null ||
|
||||
ctx.gender !== null)
|
||||
(ctx.heightCm !== null || ctx.ageYears !== null || ctx.gender !== null)
|
||||
) {
|
||||
snapshot.anthropometrics = {
|
||||
heightCm: ctx.heightCm,
|
||||
@@ -2293,9 +2462,7 @@ function degradeToBudget(
|
||||
}
|
||||
|
||||
if (degraded.length > 0) {
|
||||
const droppedClusters = Array.from(
|
||||
new Set(degraded.map((d) => d.cluster)),
|
||||
);
|
||||
const droppedClusters = Array.from(new Set(degraded.map((d) => d.cluster)));
|
||||
annotate({
|
||||
action: { name: "coach.snapshot.truncated" },
|
||||
meta: {
|
||||
|
||||
@@ -171,8 +171,7 @@ export function parseSuggestReminder(raw: string): SuggestReminderParseResult {
|
||||
const closeRel = afterOpen.indexOf(CLOSE_SENTINEL);
|
||||
// Cap the payload before parsing regardless of whether the close marker
|
||||
// is present (a missing close marker treats the rest as the body).
|
||||
const bodyRaw =
|
||||
closeRel === -1 ? afterOpen : afterOpen.slice(0, closeRel);
|
||||
const bodyRaw = closeRel === -1 ? afterOpen : afterOpen.slice(0, closeRel);
|
||||
const body =
|
||||
bodyRaw.length > SENTINEL_BYTE_CAP
|
||||
? bodyRaw.slice(0, SENTINEL_BYTE_CAP)
|
||||
@@ -183,9 +182,7 @@ export function parseSuggestReminder(raw: string): SuggestReminderParseResult {
|
||||
// it. Only the sentinel block itself is stripped; when the close marker is
|
||||
// missing there is no trailing text to recover.
|
||||
const after =
|
||||
closeRel === -1
|
||||
? ""
|
||||
: afterOpen.slice(closeRel + CLOSE_SENTINEL.length);
|
||||
closeRel === -1 ? "" : afterOpen.slice(closeRel + CLOSE_SENTINEL.length);
|
||||
const prose = `${before}${after}`.replace(/^\s+|\s+$/gu, "");
|
||||
|
||||
const cadenceId = extractCadenceId(body);
|
||||
|
||||
@@ -101,7 +101,10 @@ export async function buildTrajectorySnapshotBlock(
|
||||
continue;
|
||||
}
|
||||
if (!isDerivedOk(derived)) continue; // omit insufficient — no projection noise
|
||||
const summary = summariseTrajectory(derived.value, derived.confidence.score);
|
||||
const summary = summariseTrajectory(
|
||||
derived.value,
|
||||
derived.confidence.score,
|
||||
);
|
||||
if (!summary) continue;
|
||||
block[type] = summary;
|
||||
}
|
||||
|
||||
+44
-40
@@ -291,47 +291,51 @@ export class CodexClient implements AIProvider {
|
||||
): Promise<Response> {
|
||||
const sessionId = randomUUID();
|
||||
const threadId = randomUUID();
|
||||
return safeFetch(CODEX_ENDPOINT, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "text/event-stream",
|
||||
Authorization: `Bearer ${this.accessToken}`,
|
||||
"ChatGPT-Account-ID": this.accountId,
|
||||
originator: ORIGINATOR,
|
||||
"User-Agent": USER_AGENT,
|
||||
session_id: sessionId,
|
||||
"session-id": sessionId,
|
||||
thread_id: threadId,
|
||||
"thread-id": threadId,
|
||||
return safeFetch(
|
||||
CODEX_ENDPOINT,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "text/event-stream",
|
||||
Authorization: `Bearer ${this.accessToken}`,
|
||||
"ChatGPT-Account-ID": this.accountId,
|
||||
originator: ORIGINATOR,
|
||||
"User-Agent": USER_AGENT,
|
||||
session_id: sessionId,
|
||||
"session-id": sessionId,
|
||||
thread_id: threadId,
|
||||
"thread-id": threadId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: slug,
|
||||
instructions: params.systemPrompt,
|
||||
input: [
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: params.userPrompt }],
|
||||
},
|
||||
],
|
||||
tools: [],
|
||||
tool_choice: "auto",
|
||||
parallel_tool_calls: false,
|
||||
// Reasoning is required-but-nullable on the wire. We don't ask
|
||||
// for reasoning summaries — emit `null` so the JSON has the
|
||||
// field present and the server stops complaining about a
|
||||
// missing key (it returned 400 in earlier iterations).
|
||||
reasoning: null,
|
||||
store: false,
|
||||
stream: true,
|
||||
// Required field; empty array when not asking for reasoning.
|
||||
include: [],
|
||||
}),
|
||||
// SSE streaming completion — match the 60 s budget the other AI
|
||||
// clients use so a long generation is not clipped by the 15 s
|
||||
// default while still bounding a tar-pit upstream.
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: slug,
|
||||
instructions: params.systemPrompt,
|
||||
input: [
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: params.userPrompt }],
|
||||
},
|
||||
],
|
||||
tools: [],
|
||||
tool_choice: "auto",
|
||||
parallel_tool_calls: false,
|
||||
// Reasoning is required-but-nullable on the wire. We don't ask
|
||||
// for reasoning summaries — emit `null` so the JSON has the
|
||||
// field present and the server stops complaining about a
|
||||
// missing key (it returned 400 in earlier iterations).
|
||||
reasoning: null,
|
||||
store: false,
|
||||
stream: true,
|
||||
// Required field; empty array when not asking for reasoning.
|
||||
include: [],
|
||||
}),
|
||||
// SSE streaming completion — match the 60 s budget the other AI
|
||||
// clients use so a long generation is not clipped by the 15 s
|
||||
// default while still bounding a tar-pit upstream.
|
||||
}, { timeoutMs: 60_000 });
|
||||
{ timeoutMs: 60_000 },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -106,10 +106,7 @@ export interface CoachFeedbackAttribution extends FeedbackAttribution {
|
||||
metricSourceType: string;
|
||||
}
|
||||
|
||||
function buildCoachMetricSourceType(
|
||||
tone: string,
|
||||
verbosity: string,
|
||||
): string {
|
||||
function buildCoachMetricSourceType(tone: string, verbosity: string): string {
|
||||
return `coach:tone=${tone}:verbosity=${verbosity}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -72,25 +72,28 @@ describe("getCoachSystemPrompt — native AI-initial locales", () => {
|
||||
expect(prompt).toMatch(/BLOK DOWODÓW/);
|
||||
});
|
||||
|
||||
it.each(NATIVE_LOCALES)("%s carries every GROUND RULE body verbatim", (locale) => {
|
||||
const matrix = loadSafetyContracts(locale);
|
||||
const prompt = getCoachSystemPrompt(locale);
|
||||
for (const key of Object.keys(matrix.ground_rules) as Array<
|
||||
keyof typeof matrix.ground_rules
|
||||
>) {
|
||||
const rule = matrix.ground_rules[key];
|
||||
// Coach uses surface = "coach" or "both" rules.
|
||||
if (rule.surface === "insights") continue;
|
||||
const body = rule.locale ?? rule.en;
|
||||
expect(body).toBeDefined();
|
||||
// Match on a representative substring (first 80 chars of body).
|
||||
const sample = body!.trim().slice(0, 80);
|
||||
expect(
|
||||
prompt,
|
||||
`coach prompt for ${locale} dropped ${key} (looking for "${sample}")`,
|
||||
).toContain(sample);
|
||||
}
|
||||
});
|
||||
it.each(NATIVE_LOCALES)(
|
||||
"%s carries every GROUND RULE body verbatim",
|
||||
(locale) => {
|
||||
const matrix = loadSafetyContracts(locale);
|
||||
const prompt = getCoachSystemPrompt(locale);
|
||||
for (const key of Object.keys(matrix.ground_rules) as Array<
|
||||
keyof typeof matrix.ground_rules
|
||||
>) {
|
||||
const rule = matrix.ground_rules[key];
|
||||
// Coach uses surface = "coach" or "both" rules.
|
||||
if (rule.surface === "insights") continue;
|
||||
const body = rule.locale ?? rule.en;
|
||||
expect(body).toBeDefined();
|
||||
// Match on a representative substring (first 80 chars of body).
|
||||
const sample = body!.trim().slice(0, 80);
|
||||
expect(
|
||||
prompt,
|
||||
`coach prompt for ${locale} dropped ${key} (looking for "${sample}")`,
|
||||
).toContain(sample);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(NATIVE_LOCALES)(
|
||||
"%s preserves the ---KEYVALUES--- / ---END--- sentinels",
|
||||
|
||||
@@ -52,38 +52,10 @@ const DRUG_LEVEL_RULE_KEY = "ground_rule_15_drug_level_refusal" as const;
|
||||
const DRUG_LEVEL_CONCEPT_TOKENS: Record<Locale, readonly string[]> = {
|
||||
en: ["level", "concentration", "peak", "trough", "Cmax", "therapeutic"],
|
||||
de: ["Spiegel", "Konzentration", "Peak", "Trough", "Cmax", "therapeutisch"],
|
||||
fr: [
|
||||
"niveau",
|
||||
"concentration",
|
||||
"pic",
|
||||
"creux",
|
||||
"Cmax",
|
||||
"thérapeutique",
|
||||
],
|
||||
es: [
|
||||
"nivel",
|
||||
"concentración",
|
||||
"pico",
|
||||
"valle",
|
||||
"Cmax",
|
||||
"terapéutica",
|
||||
],
|
||||
it: [
|
||||
"livello",
|
||||
"concentrazione",
|
||||
"picco",
|
||||
"valle",
|
||||
"Cmax",
|
||||
"terapeutica",
|
||||
],
|
||||
pl: [
|
||||
"poziom",
|
||||
"stężeni",
|
||||
"szczyt",
|
||||
"minimum",
|
||||
"Cmax",
|
||||
"terapeutyczn",
|
||||
],
|
||||
fr: ["niveau", "concentration", "pic", "creux", "Cmax", "thérapeutique"],
|
||||
es: ["nivel", "concentración", "pico", "valle", "Cmax", "terapéutica"],
|
||||
it: ["livello", "concentrazione", "picco", "valle", "Cmax", "terapeutica"],
|
||||
pl: ["poziom", "stężeni", "szczyt", "minimum", "Cmax", "terapeutyczn"],
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -196,9 +168,10 @@ describe("drug-level refusal — adversarial trigger probes survive assembly", (
|
||||
// this locale. Today the prompt is static per locale, so this
|
||||
// is structurally equivalent across probes; the loop pins the
|
||||
// invariant against future per-turn prompt mutation.
|
||||
const matched = matrix.drug_level_refusal.expected_refusal_keywords.some(
|
||||
(keyword) => prompt.includes(keyword),
|
||||
);
|
||||
const matched =
|
||||
matrix.drug_level_refusal.expected_refusal_keywords.some((keyword) =>
|
||||
prompt.includes(keyword),
|
||||
);
|
||||
expect(
|
||||
matched,
|
||||
`${locale} probe="${probe.slice(0, 40)}..." found no expected refusal keyword in the Coach prompt`,
|
||||
|
||||
@@ -24,8 +24,12 @@ describe("base-system assessment prompt — locale skeleton parity", () => {
|
||||
it("every section carries both EN and DE fragments, none blank", () => {
|
||||
expect(ASSESSMENT_SECTION_PAIRS.length).toBeGreaterThan(0);
|
||||
for (const s of ASSESSMENT_SECTION_PAIRS) {
|
||||
expect(s.en.trim().length, `EN fragment for "${s.id}"`).toBeGreaterThan(0);
|
||||
expect(s.de.trim().length, `DE fragment for "${s.id}"`).toBeGreaterThan(0);
|
||||
expect(s.en.trim().length, `EN fragment for "${s.id}"`).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
expect(s.de.trim().length, `DE fragment for "${s.id}"`).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -49,8 +53,12 @@ describe("insight system prompt — locale skeleton parity", () => {
|
||||
it("every section carries both EN and DE fragments, none blank", () => {
|
||||
expect(INSIGHT_PROMPT_SECTION_PAIRS.length).toBeGreaterThan(0);
|
||||
for (const s of INSIGHT_PROMPT_SECTION_PAIRS) {
|
||||
expect(s.en.trim().length, `EN fragment for "${s.id}"`).toBeGreaterThan(0);
|
||||
expect(s.de.trim().length, `DE fragment for "${s.id}"`).toBeGreaterThan(0);
|
||||
expect(s.en.trim().length, `EN fragment for "${s.id}"`).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
expect(s.de.trim().length, `DE fragment for "${s.id}"`).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -57,4 +57,3 @@ Schreibe eine kurze Gesamteinschätzung über die verfügbaren Gesundheitsdaten:
|
||||
|
||||
${snapshotJson}`;
|
||||
}
|
||||
|
||||
@@ -27,10 +27,7 @@ import type {
|
||||
MetricStatusMeta,
|
||||
} from "@/lib/insights/metric-status-registry";
|
||||
|
||||
function directionPhrase(
|
||||
meta: MetricStatusMeta,
|
||||
locale: Locale,
|
||||
): string {
|
||||
function directionPhrase(meta: MetricStatusMeta, locale: Locale): string {
|
||||
if (locale === "en") {
|
||||
switch (meta.direction) {
|
||||
case "higher-better":
|
||||
@@ -78,10 +75,7 @@ function metaBlock(meta: MetricStatusMeta, locale: Locale): string {
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
const ARCHETYPE_SECTION: Record<
|
||||
MetricArchetype,
|
||||
{ de: string; en: string }
|
||||
> = {
|
||||
const ARCHETYPE_SECTION: Record<MetricArchetype, { de: string; en: string }> = {
|
||||
"physiological-vital": {
|
||||
en: "ARCHETYPE — PHYSIOLOGICAL VITAL:\n- This is a point-in-time physiological reading. Name the recent level, place it against both the user's own baseline (recent vs monthly/yearly mean) and, when a reference band is given, that band.\n- A single out-of-band reading is rarely a finding; a sustained shift across the recent window against the user's baseline is. Note an out-of-band trend without alarm.\n- Frame any direction (rising/falling) as a possible explanation (fitness, stress, sleep, illness, medication), never a diagnosis.",
|
||||
de: "ARCHETYP — PHYSIOLOGISCHER VITALWERT:\n- Das ist ein Momentanwert. Benenne das aktuelle Niveau und ordne es sowohl gegen die eigene Baseline (recent vs. Monats-/Jahresmittel) als auch — falls ein Referenzband vorliegt — gegen dieses Band ein.\n- Ein einzelner Wert außerhalb des Bands ist selten ein Befund; eine anhaltende Verschiebung über das recent-Fenster gegen die Baseline schon. Erwähne einen Trend außerhalb des Bands sachlich, ohne zu alarmieren.\n- Formuliere jede Richtung (steigend/fallend) als mögliche Erklärung (Fitness, Stress, Schlaf, Krankheit, Medikament), nie als Diagnose.",
|
||||
|
||||
@@ -195,10 +195,7 @@ export function loadSafetyContracts(locale: Locale): SafetyContractMatrix {
|
||||
* `.locale`. Throws if the rule has no body for the requested locale
|
||||
* (this indicates a translation gap and is caught by the parity test).
|
||||
*/
|
||||
export function getGroundRuleBody(
|
||||
locale: Locale,
|
||||
key: GroundRuleKey,
|
||||
): string {
|
||||
export function getGroundRuleBody(locale: Locale, key: GroundRuleKey): string {
|
||||
const matrix = loadSafetyContracts(locale);
|
||||
const rule = matrix.ground_rules[key];
|
||||
const body = locale === "en" ? rule.en : rule.locale;
|
||||
|
||||
@@ -51,16 +51,12 @@ export function getStatusBatchSystemPrompt(
|
||||
const en = `BATCHED OUTPUT — you are assessing SEVERAL of this user's metrics in one pass. Each metric below carries its OWN graded snapshot under a "### <key>" heading; assess each one STRICTLY from its own snapshot block, applying every rule above per metric. Do NOT let one metric's finding leak into another. Do NOT invent a metric: assess ONLY the keys present below.
|
||||
|
||||
OUTPUT FORMAT: Reply with valid JSON only, in exactly this schema. "perMetric" carries one short assessment (2-4 sentences each, the same contract as a single card) per present key:
|
||||
{ "perMetric": { ${presentKeys
|
||||
.map((k) => `"${k}": "..."`)
|
||||
.join(", ")} } }
|
||||
{ "perMetric": { ${presentKeys.map((k) => `"${k}": "..."`).join(", ")} } }
|
||||
Include a key ONLY if its snapshot block is present below (keys present: ${keyList}). Omit any metric whose block is absent — never fabricate one.`;
|
||||
const de = `GEBÜNDELTE AUSGABE — du bewertest in einem Durchgang MEHRERE Metriken dieses Nutzers. Jede Metrik unten trägt ihren EIGENEN graded snapshot unter einer Überschrift "### <key>"; bewerte jede ausschließlich aus ihrem eigenen Snapshot-Block und wende alle obigen Regeln pro Metrik an. Lass den Befund einer Metrik NICHT in eine andere überlaufen. Erfinde KEINE Metrik: bewerte NUR die unten vorhandenen Keys.
|
||||
|
||||
AUSGABEFORMAT: Antworte ausschließlich mit validem JSON in genau diesem Schema. "perMetric" enthält je vorhandenem Key eine kurze Einschätzung (je 2-4 Sätze, derselbe Vertrag wie eine Einzelkarte):
|
||||
{ "perMetric": { ${presentKeys
|
||||
.map((k) => `"${k}": "..."`)
|
||||
.join(", ")} } }
|
||||
{ "perMetric": { ${presentKeys.map((k) => `"${k}": "..."`).join(", ")} } }
|
||||
Nimm einen Key NUR auf, wenn sein Snapshot-Block unten vorhanden ist (vorhandene Keys: ${keyList}). Lass jede Metrik ohne Block weg — erfinde niemals eine.`;
|
||||
return `${base}\n\n${locale === "en" ? en : de}`;
|
||||
}
|
||||
|
||||
@@ -72,7 +72,9 @@ export interface ProviderSkipHint {
|
||||
export interface ProviderHealthLedger {
|
||||
/** Providers to skip right now for this user, keyed by chain type.
|
||||
* Fails open (empty) on any error. */
|
||||
getSkipHints(userId: string): Promise<Map<ProviderChainType, ProviderSkipHint>>;
|
||||
getSkipHints(
|
||||
userId: string,
|
||||
): Promise<Map<ProviderChainType, ProviderSkipHint>>;
|
||||
/** Record a successful generation — clears any negative cache. */
|
||||
recordSuccess(userId: string, providerType: ProviderChainType): Promise<void>;
|
||||
/** Record a hard failure. `httpStatus` null = network/transport. */
|
||||
@@ -285,7 +287,8 @@ export function createInMemoryProviderHealthLedger(): ProviderHealthLedger & {
|
||||
async recordFailure(userId, providerType, httpStatus) {
|
||||
const { result, cooldownMs } = classifyFailure(httpStatus);
|
||||
const prev = rowsFor(userId).get(providerType);
|
||||
const priorCount = prev && prev.result !== "ok" ? prev.consecutiveFailures : 0;
|
||||
const priorCount =
|
||||
prev && prev.result !== "ok" ? prev.consecutiveFailures : 0;
|
||||
rowsFor(userId).set(providerType, {
|
||||
result,
|
||||
status: httpStatus,
|
||||
|
||||
@@ -418,10 +418,7 @@ export async function hasAnyConfiguredProvider(
|
||||
where: { id: "singleton" },
|
||||
select: { adminAiKeyEncrypted: true },
|
||||
});
|
||||
return userRowHasProviderCredential(
|
||||
userRow,
|
||||
!!settings?.adminAiKeyEncrypted,
|
||||
);
|
||||
return userRowHasProviderCredential(userRow, !!settings?.adminAiKeyEncrypted);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -89,8 +89,8 @@ describe("gradeBpScore", () => {
|
||||
// floor and descends smoothly. Walk across the systolic floor (90):
|
||||
// dia 66 sits on the optimal plateau (offset −13 → 100) so the
|
||||
// systolic axis is the worst-of(sys,dia) winner across the walk.
|
||||
const sysWalk = [92, 91, 90, 89, 88].map(
|
||||
(sys) => gradeBpScore({ sys, dia: 66, target: UNDER65 }),
|
||||
const sysWalk = [92, 91, 90, 89, 88].map((sys) =>
|
||||
gradeBpScore({ sys, dia: 66, target: UNDER65 }),
|
||||
);
|
||||
// AT and ABOVE the floor sit on the plateau (100).
|
||||
expect(sysWalk[2]).toBe(100); // sys 90
|
||||
@@ -108,8 +108,8 @@ describe("gradeBpScore", () => {
|
||||
// the diastolic axis is the worst-of(sys,dia) winner throughout.
|
||||
// sys 117 sits on the optimal plateau (offset −12 → 100) so the
|
||||
// diastolic axis is the worst-of(sys,dia) winner across the walk.
|
||||
const diaWalk = [52, 51, 50, 49, 48].map(
|
||||
(dia) => gradeBpScore({ sys: 117, dia, target: UNDER65 }),
|
||||
const diaWalk = [52, 51, 50, 49, 48].map((dia) =>
|
||||
gradeBpScore({ sys: 117, dia, target: UNDER65 }),
|
||||
);
|
||||
expect(diaWalk[2]).toBe(100); // dia 50
|
||||
expect(diaWalk[1]).toBe(100); // dia 51
|
||||
@@ -177,9 +177,9 @@ describe("gradeBpScoreFromSeries", () => {
|
||||
{ at: NOW, sys: 134, dia: 87 },
|
||||
{ at: NOW, sys: 134, dia: 87 },
|
||||
];
|
||||
expect(
|
||||
gradeBpScoreFromSeries({ pairs, target: UNDER65, now: NOW }),
|
||||
).toBe(gradeBpScore({ sys: 134, dia: 87, target: UNDER65 }));
|
||||
expect(gradeBpScoreFromSeries({ pairs, target: UNDER65, now: NOW })).toBe(
|
||||
gradeBpScore({ sys: 134, dia: 87, target: UNDER65 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rollup (per-day-mean + count) and live (per-event) agree on the same data", () => {
|
||||
@@ -218,6 +218,8 @@ describe("gradeBpScoreFromSeries", () => {
|
||||
});
|
||||
expect(live).not.toBeNull();
|
||||
expect(rollup).not.toBeNull();
|
||||
expect(Math.abs((live as number) - (rollup as number))).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs((live as number) - (rollup as number))).toBeLessThanOrEqual(
|
||||
1,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -35,8 +35,10 @@ import {
|
||||
} from "@/lib/rollups/measurement-coverage";
|
||||
import { computeBpInTargetFastPath } from "../bp-in-target-fast-path";
|
||||
|
||||
const MEASUREMENT_FIND_MANY = prisma.measurement.findMany as unknown as ReturnType<typeof vi.fn>;
|
||||
const ROLLUP_FIND_MANY = prisma.measurementRollup.findMany as unknown as ReturnType<typeof vi.fn>;
|
||||
const MEASUREMENT_FIND_MANY = prisma.measurement
|
||||
.findMany as unknown as ReturnType<typeof vi.fn>;
|
||||
const ROLLUP_FIND_MANY = prisma.measurementRollup
|
||||
.findMany as unknown as ReturnType<typeof vi.fn>;
|
||||
const PROBE = probeRollupCoverage as unknown as ReturnType<typeof vi.fn>;
|
||||
const FULLY_COVERED = isFullyCovered as unknown as ReturnType<typeof vi.fn>;
|
||||
const ANNOTATE = annotate as unknown as ReturnType<typeof vi.fn>;
|
||||
@@ -223,11 +225,18 @@ describe("computeBpInTargetFastPath", () => {
|
||||
|
||||
// First findMany = SYS rows, second = DIA rows.
|
||||
MEASUREMENT_FIND_MANY.mockResolvedValueOnce([
|
||||
{ id: "1", measuredAt: new Date("2026-05-15T08:00:00.000Z"), value: 122 },
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{ id: "2", measuredAt: new Date("2026-05-15T08:00:00.000Z"), value: 76 },
|
||||
]);
|
||||
{
|
||||
id: "1",
|
||||
measuredAt: new Date("2026-05-15T08:00:00.000Z"),
|
||||
value: 122,
|
||||
},
|
||||
]).mockResolvedValueOnce([
|
||||
{
|
||||
id: "2",
|
||||
measuredAt: new Date("2026-05-15T08:00:00.000Z"),
|
||||
value: 76,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await computeBpInTargetFastPath({
|
||||
userId: "user-partial",
|
||||
|
||||
@@ -94,13 +94,29 @@ interface FixtureEvent {
|
||||
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") },
|
||||
{
|
||||
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") },
|
||||
{
|
||||
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.
|
||||
{
|
||||
@@ -254,7 +270,8 @@ describe("dailyComplianceRatesFromLedger — cadence-aware per-day series", () =
|
||||
// counted as a miss.
|
||||
const from = new Date(NOW.getTime() - 30 * DAY_MS);
|
||||
const windowRows = bundle.ledgerRows.filter(
|
||||
(r) => r.at.getTime() >= from.getTime() && r.at.getTime() <= NOW.getTime(),
|
||||
(r) =>
|
||||
r.at.getTime() >= from.getTime() && r.at.getTime() <= NOW.getTime(),
|
||||
);
|
||||
const series = dailyComplianceRatesFromLedger(windowRows);
|
||||
|
||||
@@ -269,8 +286,7 @@ describe("dailyComplianceRatesFromLedger — cadence-aware per-day series", () =
|
||||
|
||||
// The series' mean rate equals the canonical compliance30 rate: the two
|
||||
// are computed from the SAME ledger and cannot contradict each other.
|
||||
const meanRate =
|
||||
series.reduce((sum, d) => sum + d.rate, 0) / series.length;
|
||||
const meanRate = series.reduce((sum, d) => sum + d.rate, 0) / series.length;
|
||||
expect(Math.round(meanRate)).toBe(bundle.compliance30.rate);
|
||||
});
|
||||
|
||||
@@ -300,7 +316,8 @@ describe("dailyComplianceRatesFromLedger — cadence-aware per-day series", () =
|
||||
|
||||
const from = new Date(NOW.getTime() - 30 * DAY_MS);
|
||||
const windowRows = bundle.ledgerRows.filter(
|
||||
(r) => r.at.getTime() >= from.getTime() && r.at.getTime() <= NOW.getTime(),
|
||||
(r) =>
|
||||
r.at.getTime() >= from.getTime() && r.at.getTime() <= NOW.getTime(),
|
||||
);
|
||||
const series = dailyComplianceRatesFromLedger(windowRows);
|
||||
|
||||
@@ -331,7 +348,14 @@ describe("tallyLedgerRows — windowed tallies over one shared ledger", () => {
|
||||
);
|
||||
const from = new Date(NOW.getTime() - 30 * DAY_MS);
|
||||
|
||||
const rows = buildComplianceLedgerRows(events, schedules, ctx, from, NOW, NOW);
|
||||
const rows = buildComplianceLedgerRows(
|
||||
events,
|
||||
schedules,
|
||||
ctx,
|
||||
from,
|
||||
NOW,
|
||||
NOW,
|
||||
);
|
||||
expect(tallyLedgerRows(rows)).toEqual(
|
||||
tallyComplianceFromLedger(events, schedules, ctx, from, NOW, NOW),
|
||||
);
|
||||
|
||||
@@ -194,10 +194,7 @@ describe("calculateCompliance — cadence-aware adapter", () => {
|
||||
// `deletedAt: null` filter) and re-added as a fresh taken event.
|
||||
const morning = new Date("2025-01-15T07:05:00Z");
|
||||
const eveningReAdded = new Date("2025-01-15T19:10:00Z");
|
||||
const events = [
|
||||
eventAt(morning, true),
|
||||
eventAt(eveningReAdded, true),
|
||||
];
|
||||
const events = [eventAt(morning, true), eventAt(eveningReAdded, true)];
|
||||
const result = calculateCompliance(events, schedules, 1, undefined, {
|
||||
now: lateNow,
|
||||
});
|
||||
@@ -429,14 +426,16 @@ describe("calculateCompliance — engine-routed (medicationContext)", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function ctx(overrides: Partial<{
|
||||
startsOn: Date | null;
|
||||
endsOn: Date | null;
|
||||
oneShot: boolean;
|
||||
createdAt: Date;
|
||||
lastIntakeAt: Date | null;
|
||||
timeZone: string;
|
||||
}> = {}) {
|
||||
function ctx(
|
||||
overrides: Partial<{
|
||||
startsOn: Date | null;
|
||||
endsOn: Date | null;
|
||||
oneShot: boolean;
|
||||
createdAt: Date;
|
||||
lastIntakeAt: Date | null;
|
||||
timeZone: string;
|
||||
}> = {},
|
||||
) {
|
||||
return {
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
@@ -457,7 +456,12 @@ describe("calculateCompliance — engine-routed (medicationContext)", () => {
|
||||
// tail ends at 12:00) so it reads upcoming, not missed → a clean 100%.
|
||||
// (The off-time / stricter behaviour is pinned in the next test.)
|
||||
const schedules: ComplianceSchedule[] = [
|
||||
{ windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null, timesOfDay: ["08:00"] },
|
||||
{
|
||||
windowStart: "08:00",
|
||||
windowEnd: "09:00",
|
||||
daysOfWeek: null,
|
||||
timesOfDay: ["08:00"],
|
||||
},
|
||||
];
|
||||
const onTime = (d: Date) => ({
|
||||
scheduledFor: d,
|
||||
@@ -489,7 +493,12 @@ describe("calculateCompliance — engine-routed (medicationContext)", () => {
|
||||
// percentage now agrees with the history view (the dose cannot read
|
||||
// "taken" in the % while the ledger calls it ad-hoc).
|
||||
const schedules: ComplianceSchedule[] = [
|
||||
{ windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null, timesOfDay: ["08:00"] },
|
||||
{
|
||||
windowStart: "08:00",
|
||||
windowEnd: "09:00",
|
||||
daysOfWeek: null,
|
||||
timesOfDay: ["08:00"],
|
||||
},
|
||||
];
|
||||
// takenAt = 08:00 slot + 4.5h = 12:30 (eventAt adds 30min onto a 12:00
|
||||
// scheduledFor; here we pin the off-time take explicitly).
|
||||
@@ -1186,7 +1195,12 @@ describe("expectedSlotCountForDay + lastNonSkippedTakenAt", () => {
|
||||
// the 14th is 19:00 UTC on the 13th — a UTC slice would mislabel it.
|
||||
expect(userDayKey(start, tz)).toBe("2025-01-14");
|
||||
|
||||
const count = expectedSlotCountForDay(schedules, start, dayEnd, ctx({ timeZone: tz }));
|
||||
const count = expectedSlotCountForDay(
|
||||
schedules,
|
||||
start,
|
||||
dayEnd,
|
||||
ctx({ timeZone: tz }),
|
||||
);
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
@@ -1302,7 +1316,7 @@ describe("classifyIntakeTiming", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('respects the configurable `lateMinutes` tail', () => {
|
||||
it("respects the configurable `lateMinutes` tail", () => {
|
||||
// With lateMinutes=30 the late tail collapses to 30 min after the
|
||||
// 3h on-time grace. windowEnd is 09:00 so `on_time` extends to
|
||||
// 12:00 and `late` extends to 12:30. A dose at 12:15 falls in
|
||||
@@ -1733,11 +1747,21 @@ describe("buildComplianceDisplay — two rows, cadence-scaled windows", () => {
|
||||
const slot1900 = new Date("2025-06-15T19:00:00Z");
|
||||
const events = [
|
||||
// 07:00 dose logged on time.
|
||||
{ scheduledFor: slot0700, takenAt: new Date("2025-06-15T07:05:00Z"), skipped: false },
|
||||
{
|
||||
scheduledFor: slot0700,
|
||||
takenAt: new Date("2025-06-15T07:05:00Z"),
|
||||
skipped: false,
|
||||
},
|
||||
// 19:00 dose logged EARLY (before its slot) — snapped onto the 19:00 row.
|
||||
{ scheduledFor: slot1900, takenAt: new Date("2025-06-15T11:30:00Z"), skipped: false },
|
||||
{
|
||||
scheduledFor: slot1900,
|
||||
takenAt: new Date("2025-06-15T11:30:00Z"),
|
||||
skipped: false,
|
||||
},
|
||||
];
|
||||
const display = buildComplianceDisplay(events, schedules, ctx(), { now: NOW });
|
||||
const display = buildComplianceDisplay(events, schedules, ctx(), {
|
||||
now: NOW,
|
||||
});
|
||||
expect(display.currentCycle.nextDueAt).not.toBeNull();
|
||||
// Next open slot is tomorrow 07:00 UTC, not today's resolved 19:00.
|
||||
expect(display.currentCycle.nextDueAt!.toISOString()).toBe(
|
||||
@@ -1756,9 +1780,15 @@ describe("buildComplianceDisplay — two rows, cadence-scaled windows", () => {
|
||||
];
|
||||
const slot0700 = new Date("2025-06-15T07:00:00Z");
|
||||
const events = [
|
||||
{ scheduledFor: slot0700, takenAt: new Date("2025-06-15T07:05:00Z"), skipped: false },
|
||||
{
|
||||
scheduledFor: slot0700,
|
||||
takenAt: new Date("2025-06-15T07:05:00Z"),
|
||||
skipped: false,
|
||||
},
|
||||
];
|
||||
const display = buildComplianceDisplay(events, schedules, ctx(), { now: NOW });
|
||||
const display = buildComplianceDisplay(events, schedules, ctx(), {
|
||||
now: NOW,
|
||||
});
|
||||
expect(display.currentCycle.nextDueAt).not.toBeNull();
|
||||
// Today's 19:00 is still open.
|
||||
expect(display.currentCycle.nextDueAt!.toISOString()).toBe(
|
||||
@@ -2152,16 +2182,25 @@ describe("calculateCompliance — autoMissed forgotten doses (BUG #2 / #3)", ()
|
||||
},
|
||||
];
|
||||
const ctxVal = buildComplianceMedicationContext(
|
||||
{ startsOn: null, endsOn: null, oneShot: false, createdAt: new Date("2025-01-08T00:00:00Z") },
|
||||
{
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
createdAt: new Date("2025-01-08T00:00:00Z"),
|
||||
},
|
||||
lastNonSkippedTakenAt(events),
|
||||
"UTC",
|
||||
);
|
||||
const display = buildComplianceDisplay(events, daily, ctxVal, { now: NOW });
|
||||
// `expected` is the rate denominator (taken + missed), `missed` counts the
|
||||
// forgotten doses, `taken` the numerator — the "taken / expected" triple.
|
||||
expect(display.short.expected).toBe(display.short.taken + display.short.missed);
|
||||
expect(display.short.expected).toBe(
|
||||
display.short.taken + display.short.missed,
|
||||
);
|
||||
expect(display.short.missed).toBeGreaterThanOrEqual(1);
|
||||
expect(display.long.expected).toBe(display.long.taken + display.long.missed);
|
||||
expect(display.long.expected).toBe(
|
||||
display.long.taken + display.long.missed,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2223,7 +2262,11 @@ describe("calculateCompliance — irregular twice-daily taker (BUG 3 verify)", (
|
||||
// Cover EVERY slot the window emits so there is no stray uncovered slot:
|
||||
// a 3-day window (NOW − 3d = Jan-12 12:00 .. Jan-15 12:00) holds Jan-12
|
||||
// 19:00, Jan-13 07:00+19:00, Jan-14 07:00+19:00, Jan-15 07:00 = 6 slots.
|
||||
const onTime = (s: Date) => ({ scheduledFor: s, takenAt: s, skipped: false });
|
||||
const onTime = (s: Date) => ({
|
||||
scheduledFor: s,
|
||||
takenAt: s,
|
||||
skipped: false,
|
||||
});
|
||||
const events = [
|
||||
onTime(slot(12, "19:00")),
|
||||
onTime(slot(13, "07:00")),
|
||||
@@ -2231,7 +2274,9 @@ describe("calculateCompliance — irregular twice-daily taker (BUG 3 verify)", (
|
||||
// 07:00 slot, logged at 08:13 (within the late tail → taken_late).
|
||||
{
|
||||
scheduledFor: slot(14, "07:00"),
|
||||
takenAt: new Date(slot(14, "07:00").getTime() + (1 * 60 + 13) * 60 * 1000),
|
||||
takenAt: new Date(
|
||||
slot(14, "07:00").getTime() + (1 * 60 + 13) * 60 * 1000,
|
||||
),
|
||||
skipped: false,
|
||||
},
|
||||
onTime(slot(14, "19:00")),
|
||||
@@ -2304,7 +2349,11 @@ describe("calculateCompliance — irregular twice-daily taker (BUG 3 verify)", (
|
||||
skipped: false,
|
||||
autoMissed: true,
|
||||
});
|
||||
const extra = (s: Date) => ({ scheduledFor: s, takenAt: s, skipped: false });
|
||||
const extra = (s: Date) => ({
|
||||
scheduledFor: s,
|
||||
takenAt: s,
|
||||
skipped: false,
|
||||
});
|
||||
|
||||
const events = [
|
||||
taken(slot(8, "19:00"), 20, 10),
|
||||
@@ -2355,35 +2404,35 @@ describe("deriveDoseStatus — daily / intraday windows", () => {
|
||||
const target = new Date("2025-01-15T08:00:00Z");
|
||||
|
||||
it("on-time within ±60 min of target", () => {
|
||||
expect(deriveDoseStatus(target, "daily", new Date("2025-01-15T08:30:00Z"))).toBe(
|
||||
"on_time_window",
|
||||
);
|
||||
expect(deriveDoseStatus(target, "daily", new Date("2025-01-15T07:15:00Z"))).toBe(
|
||||
"on_time_window",
|
||||
);
|
||||
expect(
|
||||
deriveDoseStatus(target, "daily", new Date("2025-01-15T08:30:00Z")),
|
||||
).toBe("on_time_window");
|
||||
expect(
|
||||
deriveDoseStatus(target, "daily", new Date("2025-01-15T07:15:00Z")),
|
||||
).toBe("on_time_window");
|
||||
});
|
||||
|
||||
it("upcoming before the on-time window opens", () => {
|
||||
expect(deriveDoseStatus(target, "daily", new Date("2025-01-15T06:30:00Z"))).toBe(
|
||||
"upcoming",
|
||||
);
|
||||
expect(
|
||||
deriveDoseStatus(target, "daily", new Date("2025-01-15T06:30:00Z")),
|
||||
).toBe("upcoming");
|
||||
});
|
||||
|
||||
it("overdue between +60 min and +240 min", () => {
|
||||
// +90 min → past on-time (60), before miss cutoff (240).
|
||||
expect(deriveDoseStatus(target, "daily", new Date("2025-01-15T09:30:00Z"))).toBe(
|
||||
"overdue",
|
||||
);
|
||||
expect(
|
||||
deriveDoseStatus(target, "daily", new Date("2025-01-15T09:30:00Z")),
|
||||
).toBe("overdue");
|
||||
// +239 min → still overdue (takeable).
|
||||
expect(deriveDoseStatus(target, "daily", new Date("2025-01-15T11:59:00Z"))).toBe(
|
||||
"overdue",
|
||||
);
|
||||
expect(
|
||||
deriveDoseStatus(target, "daily", new Date("2025-01-15T11:59:00Z")),
|
||||
).toBe("overdue");
|
||||
});
|
||||
|
||||
it("missed past +240 min", () => {
|
||||
expect(deriveDoseStatus(target, "daily", new Date("2025-01-15T12:30:00Z"))).toBe(
|
||||
"missed",
|
||||
);
|
||||
expect(
|
||||
deriveDoseStatus(target, "daily", new Date("2025-01-15T12:30:00Z")),
|
||||
).toBe("missed");
|
||||
});
|
||||
|
||||
it("never overlaps the next dose's on-time window", () => {
|
||||
@@ -2424,19 +2473,19 @@ describe("deriveDoseStatus — weekly GLP-1 (4-day rule)", () => {
|
||||
const target = new Date("2025-01-13T08:00:00Z"); // a Monday shot
|
||||
|
||||
it("on-time within ±1 day of the target day", () => {
|
||||
expect(deriveDoseStatus(target, "weekly", new Date("2025-01-13T20:00:00Z"))).toBe(
|
||||
"on_time_window",
|
||||
);
|
||||
expect(deriveDoseStatus(target, "weekly", new Date("2025-01-14T07:00:00Z"))).toBe(
|
||||
"on_time_window",
|
||||
);
|
||||
expect(
|
||||
deriveDoseStatus(target, "weekly", new Date("2025-01-13T20:00:00Z")),
|
||||
).toBe("on_time_window");
|
||||
expect(
|
||||
deriveDoseStatus(target, "weekly", new Date("2025-01-14T07:00:00Z")),
|
||||
).toBe("on_time_window");
|
||||
});
|
||||
|
||||
it("overdue up to +4 days (still counts when taken — the clinical rule)", () => {
|
||||
// +3 days from target → past the ±1-day on-time window, before +4 days.
|
||||
expect(deriveDoseStatus(target, "weekly", new Date("2025-01-16T12:00:00Z"))).toBe(
|
||||
"overdue",
|
||||
);
|
||||
expect(
|
||||
deriveDoseStatus(target, "weekly", new Date("2025-01-16T12:00:00Z")),
|
||||
).toBe("overdue");
|
||||
// Taken on day +3 still counts as late, not missed.
|
||||
expect(
|
||||
deriveDoseStatus(target, "weekly", new Date("2025-01-20T00:00:00Z"), {
|
||||
@@ -2446,15 +2495,16 @@ describe("deriveDoseStatus — weekly GLP-1 (4-day rule)", () => {
|
||||
});
|
||||
|
||||
it("missed past +4 days", () => {
|
||||
expect(deriveDoseStatus(target, "weekly", new Date("2025-01-18T12:00:00Z"))).toBe(
|
||||
"missed",
|
||||
);
|
||||
expect(
|
||||
deriveDoseStatus(target, "weekly", new Date("2025-01-18T12:00:00Z")),
|
||||
).toBe("missed");
|
||||
});
|
||||
|
||||
it("defaults match the documented 60-min / 240-min / 4-day boundaries", () => {
|
||||
expect(DOSE_WINDOW_DEFAULTS.dailyOnTimeMinutes).toBe(60);
|
||||
expect(
|
||||
DOSE_WINDOW_DEFAULTS.dailyOnTimeMinutes + DOSE_WINDOW_DEFAULTS.dailyOverdueMinutes,
|
||||
DOSE_WINDOW_DEFAULTS.dailyOnTimeMinutes +
|
||||
DOSE_WINDOW_DEFAULTS.dailyOverdueMinutes,
|
||||
).toBe(240);
|
||||
expect(DOSE_WINDOW_DEFAULTS.weeklyOverdueDays).toBe(4);
|
||||
});
|
||||
@@ -2463,7 +2513,11 @@ describe("deriveDoseStatus — weekly GLP-1 (4-day rule)", () => {
|
||||
describe("doseCadenceFamily", () => {
|
||||
it("rolling ≥ 2 days → weekly", () => {
|
||||
expect(
|
||||
doseCadenceFamily({ windowStart: "08:00", windowEnd: "09:00", rollingIntervalDays: 7 }),
|
||||
doseCadenceFamily({
|
||||
windowStart: "08:00",
|
||||
windowEnd: "09:00",
|
||||
rollingIntervalDays: 7,
|
||||
}),
|
||||
).toBe("weekly");
|
||||
});
|
||||
it("WEEKLY rrule → weekly", () => {
|
||||
@@ -2477,12 +2531,20 @@ describe("doseCadenceFamily", () => {
|
||||
});
|
||||
it("daily rrule → daily", () => {
|
||||
expect(
|
||||
doseCadenceFamily({ windowStart: "08:00", windowEnd: "09:00", rrule: "FREQ=DAILY" }),
|
||||
doseCadenceFamily({
|
||||
windowStart: "08:00",
|
||||
windowEnd: "09:00",
|
||||
rrule: "FREQ=DAILY",
|
||||
}),
|
||||
).toBe("daily");
|
||||
});
|
||||
it("plain legacy daily → daily", () => {
|
||||
expect(
|
||||
doseCadenceFamily({ windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null }),
|
||||
doseCadenceFamily({
|
||||
windowStart: "08:00",
|
||||
windowEnd: "09:00",
|
||||
daysOfWeek: null,
|
||||
}),
|
||||
).toBe("daily");
|
||||
});
|
||||
});
|
||||
@@ -2506,7 +2568,10 @@ import {
|
||||
reconstructDoseHistory,
|
||||
type HistoryIntake,
|
||||
} from "@/lib/medications/scheduling/dose-history";
|
||||
import type { CanonicalSchedule, RecurrenceContext } from "@/lib/medications/scheduling/recurrence";
|
||||
import type {
|
||||
CanonicalSchedule,
|
||||
RecurrenceContext,
|
||||
} from "@/lib/medications/scheduling/recurrence";
|
||||
|
||||
describe("tallyComplianceFromLedger — the unified % keystone", () => {
|
||||
const TZ = "Europe/Berlin";
|
||||
|
||||
@@ -40,10 +40,15 @@ import {
|
||||
computeCorrelationHypothesesFastPath,
|
||||
} from "../correlations-fast-path";
|
||||
|
||||
const MEASUREMENT_FIND_MANY = prisma.measurement.findMany as unknown as ReturnType<typeof vi.fn>;
|
||||
const ROLLUP_FIND_MANY = prisma.measurementRollup.findMany as unknown as ReturnType<typeof vi.fn>;
|
||||
const MOOD_FIND_MANY = prisma.moodEntry.findMany as unknown as ReturnType<typeof vi.fn>;
|
||||
const INTAKE_FIND_MANY = prisma.medicationIntakeEvent.findMany as unknown as ReturnType<typeof vi.fn>;
|
||||
const MEASUREMENT_FIND_MANY = prisma.measurement
|
||||
.findMany as unknown as ReturnType<typeof vi.fn>;
|
||||
const ROLLUP_FIND_MANY = prisma.measurementRollup
|
||||
.findMany as unknown as ReturnType<typeof vi.fn>;
|
||||
const MOOD_FIND_MANY = prisma.moodEntry.findMany as unknown as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
const INTAKE_FIND_MANY = prisma.medicationIntakeEvent
|
||||
.findMany as unknown as ReturnType<typeof vi.fn>;
|
||||
const PROBE = probeRollupCoverage as unknown as ReturnType<typeof vi.fn>;
|
||||
const FULLY_COVERED = isFullyCovered as unknown as ReturnType<typeof vi.fn>;
|
||||
const ANNOTATE = annotate as unknown as ReturnType<typeof vi.fn>;
|
||||
@@ -82,8 +87,7 @@ describe("computeCorrelationHypothesesFastPath", () => {
|
||||
FULLY_COVERED.mockReturnValue(true);
|
||||
PROBE.mockResolvedValue(coverage);
|
||||
|
||||
ROLLUP_FIND_MANY
|
||||
.mockResolvedValueOnce([]) // SYS buckets
|
||||
ROLLUP_FIND_MANY.mockResolvedValueOnce([]) // SYS buckets
|
||||
.mockResolvedValueOnce([]) // PULSE buckets
|
||||
.mockResolvedValueOnce([]); // WEIGHT buckets
|
||||
|
||||
@@ -141,8 +145,7 @@ describe("computeCorrelationHypothesesFastPath", () => {
|
||||
FULLY_COVERED.mockReturnValue(false);
|
||||
PROBE.mockResolvedValue(coverage);
|
||||
|
||||
MEASUREMENT_FIND_MANY
|
||||
.mockResolvedValueOnce([]) // SYS rows
|
||||
MEASUREMENT_FIND_MANY.mockResolvedValueOnce([]) // SYS rows
|
||||
.mockResolvedValueOnce([]) // PULSE rows
|
||||
.mockResolvedValueOnce([]); // WEIGHT rows
|
||||
|
||||
@@ -250,7 +253,9 @@ describe("computeCorrelationHypothesesFastPath", () => {
|
||||
|
||||
const calls = ANNOTATE.mock.calls.map((c) => c[0]);
|
||||
const corrCall = calls.find((c) => c?.meta?.correlations !== undefined);
|
||||
expect(corrCall?.meta.correlations.tz_guard).toBe("non-utc-live-fallback");
|
||||
expect(corrCall?.meta.correlations.tz_guard).toBe(
|
||||
"non-utc-live-fallback",
|
||||
);
|
||||
expect(corrCall?.meta.correlations.path).toBe("live");
|
||||
});
|
||||
|
||||
|
||||
@@ -102,4 +102,3 @@ describe("pearsonCorrelation", () => {
|
||||
expect(result.strength).toBe("keine");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -254,13 +254,10 @@ describe("computeGlucoseClinicalMetrics — window + adequacy", () => {
|
||||
// 30/day. That clears the bar legitimately, but a handful in a day must
|
||||
// not: 10 readings in one day = 10/day < 24 → still a spot estimate.
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
const readings: GlucoseReading[] = Array.from(
|
||||
{ length: 10 },
|
||||
(_, i) => ({
|
||||
mgdl: 115,
|
||||
measuredAt: new Date(now.getTime() - i * HOUR),
|
||||
}),
|
||||
);
|
||||
const readings: GlucoseReading[] = Array.from({ length: 10 }, (_, i) => ({
|
||||
mgdl: 115,
|
||||
measuredAt: new Date(now.getTime() - i * HOUR),
|
||||
}));
|
||||
const m = computeGlucoseClinicalMetrics(readings, { now });
|
||||
expect(m.isSpotEstimate).toBe(true);
|
||||
});
|
||||
@@ -309,10 +306,9 @@ describe("computeGlucoseClinicalMetrics — window + adequacy", () => {
|
||||
it("clears above both thresholds and asserts the panel", () => {
|
||||
// 14 readings, one per day → 13-day span, count 14
|
||||
const values = Array.from({ length: 14 }, () => 154);
|
||||
const m = computeGlucoseClinicalMetrics(
|
||||
dailyReadings(values, now),
|
||||
{ now },
|
||||
);
|
||||
const m = computeGlucoseClinicalMetrics(dailyReadings(values, now), {
|
||||
now,
|
||||
});
|
||||
expect(m.readingCount).toBe(14);
|
||||
expect(m.actualSpanDays).toBeCloseTo(13, 6);
|
||||
expect(m.stillLearning).toBe(false);
|
||||
|
||||
@@ -38,9 +38,7 @@ function readings() {
|
||||
}
|
||||
|
||||
/** Coach projection — mirrors `snapshot.glucose.clinical` (asserted branch). */
|
||||
function coachProjection(
|
||||
m: ReturnType<typeof computeGlucoseClinicalMetrics>,
|
||||
) {
|
||||
function coachProjection(m: ReturnType<typeof computeGlucoseClinicalMetrics>) {
|
||||
return {
|
||||
tirPercent: m.distribution ? Math.round(m.distribution.tir * 100) : null,
|
||||
gmi: m.gmi !== null ? Math.round(m.gmi * 10) / 10 : null,
|
||||
@@ -52,9 +50,7 @@ function coachProjection(
|
||||
}
|
||||
|
||||
/** Panel projection — mirrors the panel's display rounding. */
|
||||
function panelProjection(
|
||||
m: ReturnType<typeof computeGlucoseClinicalMetrics>,
|
||||
) {
|
||||
function panelProjection(m: ReturnType<typeof computeGlucoseClinicalMetrics>) {
|
||||
return {
|
||||
tirPercent: m.distribution ? Math.round(m.distribution.tir * 100) : null,
|
||||
gmi: m.gmi !== null ? Number(m.gmi.toFixed(1)) : null,
|
||||
|
||||
@@ -12,7 +12,10 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_WINDOW_DAYS, GLUCOSE_PANEL_WINDOW_DAYS } from "../glucose-metrics";
|
||||
import {
|
||||
DEFAULT_WINDOW_DAYS,
|
||||
GLUCOSE_PANEL_WINDOW_DAYS,
|
||||
} from "../glucose-metrics";
|
||||
|
||||
const ROOT = join(__dirname, "..", "..", "..", "..");
|
||||
|
||||
@@ -43,7 +46,9 @@ describe("glucose panel window constant", () => {
|
||||
// It must NOT be a bare `windowDays: 30` glucose literal.
|
||||
const usesConstantDirectly = /GLUCOSE_PANEL_WINDOW_DAYS/.test(src);
|
||||
const aliasesConstant =
|
||||
/GLUCOSE_CLINICAL_WINDOW_DAYS\s*=\s*GLUCOSE_PANEL_WINDOW_DAYS/.test(src);
|
||||
/GLUCOSE_CLINICAL_WINDOW_DAYS\s*=\s*GLUCOSE_PANEL_WINDOW_DAYS/.test(
|
||||
src,
|
||||
);
|
||||
expect(usesConstantDirectly || aliasesConstant).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -37,11 +37,17 @@ import {
|
||||
} from "@/lib/rollups/measurement-coverage";
|
||||
import { computeUserHealthScoreFastPath } from "../health-score-fast-path";
|
||||
|
||||
const MEASUREMENT_FIND_MANY = prisma.measurement.findMany as unknown as ReturnType<typeof vi.fn>;
|
||||
const ROLLUP_FIND_MANY = prisma.measurementRollup.findMany as unknown as ReturnType<typeof vi.fn>;
|
||||
const MOOD_FIND_MANY = prisma.moodEntry.findMany as unknown as ReturnType<typeof vi.fn>;
|
||||
const MEDICATION_FIND_MANY = prisma.medication.findMany as unknown as ReturnType<typeof vi.fn>;
|
||||
const INTAKE_FIND_MANY = prisma.medicationIntakeEvent.findMany as unknown as ReturnType<typeof vi.fn>;
|
||||
const MEASUREMENT_FIND_MANY = prisma.measurement
|
||||
.findMany as unknown as ReturnType<typeof vi.fn>;
|
||||
const ROLLUP_FIND_MANY = prisma.measurementRollup
|
||||
.findMany as unknown as ReturnType<typeof vi.fn>;
|
||||
const MOOD_FIND_MANY = prisma.moodEntry.findMany as unknown as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
const MEDICATION_FIND_MANY = prisma.medication
|
||||
.findMany as unknown as ReturnType<typeof vi.fn>;
|
||||
const INTAKE_FIND_MANY = prisma.medicationIntakeEvent
|
||||
.findMany as unknown as ReturnType<typeof vi.fn>;
|
||||
const PROBE = probeRollupCoverage as unknown as ReturnType<typeof vi.fn>;
|
||||
const FULLY_COVERED = isFullyCovered as unknown as ReturnType<typeof vi.fn>;
|
||||
const ANNOTATE = annotate as unknown as ReturnType<typeof vi.fn>;
|
||||
@@ -110,17 +116,16 @@ describe("computeUserHealthScoreFastPath", () => {
|
||||
// null` and the score shape is unchanged.
|
||||
ROLLUP_FIND_MANY.mockResolvedValueOnce([]);
|
||||
// Weight source-attribution read (narrow 2-column).
|
||||
MEASUREMENT_FIND_MANY
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
measuredAt: new Date("2026-05-10T08:00:00.000Z"),
|
||||
source: "WITHINGS",
|
||||
},
|
||||
{
|
||||
measuredAt: new Date("2026-05-14T08:00:00.000Z"),
|
||||
source: "WITHINGS",
|
||||
},
|
||||
])
|
||||
MEASUREMENT_FIND_MANY.mockResolvedValueOnce([
|
||||
{
|
||||
measuredAt: new Date("2026-05-10T08:00:00.000Z"),
|
||||
source: "WITHINGS",
|
||||
},
|
||||
{
|
||||
measuredAt: new Date("2026-05-14T08:00:00.000Z"),
|
||||
source: "WITHINGS",
|
||||
},
|
||||
])
|
||||
// BP-SYS source attribution read (parallel after rollup).
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
@@ -311,18 +316,14 @@ describe("computeUserHealthScoreFastPath", () => {
|
||||
// raw weight findMany is already doing the work.
|
||||
expect(ROLLUP_FIND_MANY).not.toHaveBeenCalled();
|
||||
const calls = ANNOTATE.mock.calls.map((c) => c[0]);
|
||||
const pathCall = calls.find(
|
||||
(c) => c?.meta?.healthScore?.path === "live",
|
||||
);
|
||||
const pathCall = calls.find((c) => c?.meta?.healthScore?.path === "live");
|
||||
expect(pathCall?.meta?.healthScore?.weightLongWindow).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("live fallback — WEIGHT not covered", () => {
|
||||
it("reads raw weight rows and pins path:live", async () => {
|
||||
const coverage = new Map<string, boolean>([
|
||||
["WEIGHT", false],
|
||||
]);
|
||||
const coverage = new Map<string, boolean>([["WEIGHT", false]]);
|
||||
FULLY_COVERED.mockReturnValue(false);
|
||||
PROBE.mockResolvedValue(coverage);
|
||||
|
||||
@@ -354,9 +355,7 @@ describe("computeUserHealthScoreFastPath", () => {
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
const calls = ANNOTATE.mock.calls.map((c) => c[0]);
|
||||
const pathCall = calls.find(
|
||||
(c) => c?.meta?.healthScore?.path === "live",
|
||||
);
|
||||
const pathCall = calls.find((c) => c?.meta?.healthScore?.path === "live");
|
||||
expect(pathCall).toBeDefined();
|
||||
// Live path read raw weight (value selected).
|
||||
const weightCall = MEASUREMENT_FIND_MANY.mock.calls[0][0];
|
||||
@@ -491,9 +490,9 @@ describe("computeUserHealthScoreFastPath", () => {
|
||||
// care here that the two windows are NOT pinned to the same
|
||||
// value any more.
|
||||
const componentDelta = result?.delta;
|
||||
expect(typeof componentDelta === "number" || componentDelta === null).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
typeof componentDelta === "number" || componentDelta === null,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to bpInTargetPct when bpInTargetPctPriorWeek is omitted", async () => {
|
||||
@@ -802,8 +801,7 @@ describe("computeUserHealthScoreFastPath", () => {
|
||||
|
||||
const now = new Date("2026-06-07T12:00:00.000Z");
|
||||
// Live fallback — no weight/mood/med data; only BP signal present.
|
||||
MEASUREMENT_FIND_MANY
|
||||
.mockResolvedValueOnce([]) // weight raw read (live fallback)
|
||||
MEASUREMENT_FIND_MANY.mockResolvedValueOnce([]) // weight raw read (live fallback)
|
||||
.mockResolvedValueOnce([]); // BP-SYS source attribution
|
||||
|
||||
const result = await computeUserHealthScoreFastPath({
|
||||
@@ -828,8 +826,7 @@ describe("computeUserHealthScoreFastPath", () => {
|
||||
PROBE.mockResolvedValue(coverage);
|
||||
|
||||
const now = new Date("2026-06-07T12:00:00.000Z");
|
||||
MEASUREMENT_FIND_MANY
|
||||
.mockResolvedValueOnce([]) // weight raw read
|
||||
MEASUREMENT_FIND_MANY.mockResolvedValueOnce([]) // weight raw read
|
||||
.mockResolvedValueOnce([]); // BP-SYS source attribution
|
||||
// Mood present so the score is computable even without BP.
|
||||
MOOD_FIND_MANY.mockResolvedValue(
|
||||
|
||||
@@ -76,7 +76,11 @@ describe("buildHealthScoreBpInputs — single Health-Score BP input builder", ()
|
||||
// path), but here it is genuinely thin so the rate is the all-time pct.
|
||||
// Pin the stricter case: when all-time is also null the pillar collapses.
|
||||
const thin = buildHealthScoreBpInputs(
|
||||
env({ last90Days: { pct: 100, pairs: 4 }, allTime: null, gradedScore: 95 }),
|
||||
env({
|
||||
last90Days: { pct: 100, pairs: 4 },
|
||||
allTime: null,
|
||||
gradedScore: 95,
|
||||
}),
|
||||
null,
|
||||
);
|
||||
expect(thin.bpInTargetPct).toBeNull();
|
||||
|
||||
@@ -101,7 +101,10 @@ describe("weightTrendAlignment", () => {
|
||||
it("scores the bare trend (non-null) when the target is missing but ≥2 readings exist", () => {
|
||||
// No target → fall back to trend-only scoring instead of returning
|
||||
// null, so a height-less user with weight data gets a populated pillar.
|
||||
const result = weightTrendAlignment(weightSeries([80, 80.5, 81, 81.5]), null);
|
||||
const result = weightTrendAlignment(
|
||||
weightSeries([80, 80.5, 81, 81.5]),
|
||||
null,
|
||||
);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!).toBeGreaterThanOrEqual(0);
|
||||
expect(result!).toBeLessThanOrEqual(100);
|
||||
@@ -112,13 +115,19 @@ describe("weightTrendAlignment", () => {
|
||||
});
|
||||
|
||||
it("rewards a gentle decline (>75) when there is no target", () => {
|
||||
const result = weightTrendAlignment(weightSeries([84, 83.5, 83, 82.5]), null);
|
||||
const result = weightTrendAlignment(
|
||||
weightSeries([84, 83.5, 83, 82.5]),
|
||||
null,
|
||||
);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!).toBeGreaterThan(75);
|
||||
});
|
||||
|
||||
it("penalises a sustained rise (<75) when there is no target", () => {
|
||||
const result = weightTrendAlignment(weightSeries([80, 81, 82, 83, 84]), null);
|
||||
const result = weightTrendAlignment(
|
||||
weightSeries([80, 81, 82, 83, 84]),
|
||||
null,
|
||||
);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!).toBeLessThan(75);
|
||||
});
|
||||
|
||||
@@ -23,7 +23,9 @@ describe("deriveRestingProxyFromPulse", () => {
|
||||
// One Berlin day: 10 resting reads ~70, 500 workout reads ~150.
|
||||
const samples: PulseSample[] = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
samples.push(day(`2026-06-01T06:${String(i).padStart(2, "0")}:00`, 68 + i));
|
||||
samples.push(
|
||||
day(`2026-06-01T06:${String(i).padStart(2, "0")}:00`, 68 + i),
|
||||
);
|
||||
}
|
||||
for (let i = 0; i < 500; i++) {
|
||||
samples.push(
|
||||
@@ -48,7 +50,12 @@ describe("deriveRestingProxyFromPulse", () => {
|
||||
// Realistic day: 30 waking/spot reads 70-95, 10 workout reads ~150.
|
||||
const samples: PulseSample[] = [];
|
||||
for (let i = 0; i < 30; i++) {
|
||||
samples.push(day(`2026-06-02T08:${String(i % 60).padStart(2, "0")}:00`, 70 + (i % 25)));
|
||||
samples.push(
|
||||
day(
|
||||
`2026-06-02T08:${String(i % 60).padStart(2, "0")}:00`,
|
||||
70 + (i % 25),
|
||||
),
|
||||
);
|
||||
}
|
||||
for (let i = 0; i < 10; i++) {
|
||||
samples.push(day(`2026-06-02T18:${String(i).padStart(2, "0")}:00`, 150));
|
||||
@@ -107,7 +114,10 @@ describe("deriveRestingProxyFromPulse", () => {
|
||||
describe("resolveRestingPulseSeries", () => {
|
||||
it("prefers RESTING_HEART_RATE — a clean ~72 series reads as resting", () => {
|
||||
const restingSamples: PulseSample[] = Array.from({ length: 30 }, (_v, i) =>
|
||||
day(`2026-06-${String((i % 28) + 1).padStart(2, "0")}T06:00:00`, 70 + (i % 5)),
|
||||
day(
|
||||
`2026-06-${String((i % 28) + 1).padStart(2, "0")}T06:00:00`,
|
||||
70 + (i % 5),
|
||||
),
|
||||
);
|
||||
// Workout-polluted PULSE that should be IGNORED in favour of resting.
|
||||
const pulseSamples: PulseSample[] = Array.from({ length: 500 }, () =>
|
||||
|
||||
@@ -486,9 +486,7 @@ describe("reconstructSleepSessions", () => {
|
||||
expect(sessions[0].source).toBe("APPLE_HEALTH");
|
||||
// The surviving segments carry a real timeline: more than one
|
||||
// distinct end instant.
|
||||
const ends = new Set(
|
||||
sessions[0].segments.map((seg) => seg.end.getTime()),
|
||||
);
|
||||
const ends = new Set(sessions[0].segments.map((seg) => seg.end.getTime()));
|
||||
expect(ends.size).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -243,12 +243,7 @@ describe("pickCanonicalSourceRows — v1.4.25 W8c two-axis device-type picker",
|
||||
default: ["phone", "watch"],
|
||||
},
|
||||
};
|
||||
const out = pickCanonicalSourceRows(
|
||||
rows,
|
||||
"steps",
|
||||
userPriority,
|
||||
isoDayKey,
|
||||
);
|
||||
const out = pickCanonicalSourceRows(rows, "steps", userPriority, isoDayKey);
|
||||
expect(out.canonicalRows).toHaveLength(1);
|
||||
expect(out.canonicalRows[0].deviceType).toBe("phone");
|
||||
expect(out.canonicalRows[0].value).toBe(4100);
|
||||
@@ -280,12 +275,7 @@ describe("pickCanonicalSourceRows — v1.4.25 W8c two-axis device-type picker",
|
||||
ACTIVITY_STEPS: ["phone", "watch"],
|
||||
},
|
||||
};
|
||||
const out = pickCanonicalSourceRows(
|
||||
rows,
|
||||
"steps",
|
||||
userPriority,
|
||||
isoDayKey,
|
||||
);
|
||||
const out = pickCanonicalSourceRows(rows, "steps", userPriority, isoDayKey);
|
||||
expect(out.canonicalRows).toHaveLength(1);
|
||||
expect(out.canonicalRows[0].deviceType).toBe("phone");
|
||||
});
|
||||
@@ -365,12 +355,7 @@ describe("pickCanonicalSourceRows — v1.4.25 W8c two-axis device-type picker",
|
||||
default: ["ring"],
|
||||
},
|
||||
};
|
||||
const out = pickCanonicalSourceRows(
|
||||
rows,
|
||||
"steps",
|
||||
userPriority,
|
||||
isoDayKey,
|
||||
);
|
||||
const out = pickCanonicalSourceRows(rows, "steps", userPriority, isoDayKey);
|
||||
expect(out.canonicalRows).toHaveLength(2);
|
||||
});
|
||||
|
||||
@@ -425,12 +410,7 @@ describe("pickCanonicalSourceRows — v1.4.25 W8c two-axis device-type picker",
|
||||
ACTIVITY_STEPS: ["watch", "phone"],
|
||||
},
|
||||
};
|
||||
const out = pickCanonicalSourceRows(
|
||||
rows,
|
||||
"steps",
|
||||
userPriority,
|
||||
isoDayKey,
|
||||
);
|
||||
const out = pickCanonicalSourceRows(rows, "steps", userPriority, isoDayKey);
|
||||
// Expect: WEIGHT's scale row + ACTIVITY_STEPS' watch row. Both
|
||||
// types' losers (WEIGHT's watch row, ACTIVITY_STEPS' phone row)
|
||||
// are dropped against their OWN ladder, not the bucket's first
|
||||
@@ -485,12 +465,7 @@ describe("pickCanonicalSourceRows — v1.4.25 W8c two-axis device-type picker",
|
||||
WEIGHT: ["ring"],
|
||||
},
|
||||
};
|
||||
const out = pickCanonicalSourceRows(
|
||||
rows,
|
||||
"steps",
|
||||
userPriority,
|
||||
isoDayKey,
|
||||
);
|
||||
const out = pickCanonicalSourceRows(rows, "steps", userPriority, isoDayKey);
|
||||
// 2 WEIGHT rows kept (per-type fall-through) + 1 ACTIVITY_STEPS
|
||||
// watch row (phone dropped against default ladder).
|
||||
expect(out.canonicalRows).toHaveLength(3);
|
||||
|
||||
@@ -69,16 +69,18 @@ import { computeSummariesSlice } from "../summaries-slice";
|
||||
|
||||
const RAW = prisma.$queryRaw as unknown as ReturnType<typeof vi.fn>;
|
||||
const UNSAFE = prisma.$queryRawUnsafe as unknown as ReturnType<typeof vi.fn>;
|
||||
const USER_FIND_UNIQUE =
|
||||
prisma.user.findUnique as unknown as ReturnType<typeof vi.fn>;
|
||||
const MEASUREMENT_FIND_FIRST =
|
||||
prisma.measurement.findFirst as unknown as ReturnType<typeof vi.fn>;
|
||||
const ROLLUP_FIND_MANY =
|
||||
prisma.measurementRollup.findMany as unknown as ReturnType<typeof vi.fn>;
|
||||
const ROLLUP_FIND_FIRST =
|
||||
prisma.measurementRollup.findFirst as unknown as ReturnType<typeof vi.fn>;
|
||||
const CANONICAL_FROM =
|
||||
canonicalMeasurementsFrom as unknown as ReturnType<typeof vi.fn>;
|
||||
const USER_FIND_UNIQUE = prisma.user.findUnique as unknown as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
const MEASUREMENT_FIND_FIRST = prisma.measurement
|
||||
.findFirst as unknown as ReturnType<typeof vi.fn>;
|
||||
const ROLLUP_FIND_MANY = prisma.measurementRollup
|
||||
.findMany as unknown as ReturnType<typeof vi.fn>;
|
||||
const ROLLUP_FIND_FIRST = prisma.measurementRollup
|
||||
.findFirst as unknown as ReturnType<typeof vi.fn>;
|
||||
const CANONICAL_FROM = canonicalMeasurementsFrom as unknown as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
|
||||
beforeEach(() => {
|
||||
RAW.mockReset();
|
||||
@@ -603,4 +605,3 @@ describe("computeSummariesSlice", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
} from "../window-confidence";
|
||||
|
||||
const NOW = new Date("2026-06-14T12:00:00.000Z");
|
||||
const daysAgo = (n: number) => new Date(NOW.getTime() - n * 24 * 60 * 60 * 1000);
|
||||
const daysAgo = (n: number) =>
|
||||
new Date(NOW.getTime() - n * 24 * 60 * 60 * 1000);
|
||||
|
||||
describe("computeWindowConfidence", () => {
|
||||
it("flags a sample below the floor as insufficient", () => {
|
||||
|
||||
@@ -51,10 +51,7 @@
|
||||
* Pure & deterministic — fully unit-tested in `__tests__/bp-grade.test.ts`.
|
||||
*/
|
||||
import type { BpTargets } from "./bp-targets";
|
||||
import {
|
||||
SYS_HYPOTENSION_FLOOR,
|
||||
DIA_HYPOTENSION_FLOOR,
|
||||
} from "./bp-in-target";
|
||||
import { SYS_HYPOTENSION_FLOOR, DIA_HYPOTENSION_FLOOR } from "./bp-in-target";
|
||||
|
||||
/**
|
||||
* Offset-from-ceiling → axis-score anchors. Offsets are in mmHg; the
|
||||
|
||||
@@ -310,7 +310,12 @@ async function computeFromRollups(
|
||||
// All-time on the rollup path is bounded to the 395-day read window.
|
||||
// Documented above — same trade-off the per-event path makes when the
|
||||
// user's history extends beyond 365 days.
|
||||
const allTime = bucketWindow(pairsByDay, threeNinetyFiveDaysAgo, now, targets);
|
||||
const allTime = bucketWindow(
|
||||
pairsByDay,
|
||||
threeNinetyFiveDaysAgo,
|
||||
now,
|
||||
targets,
|
||||
);
|
||||
const priorMonth = bucketWindow(
|
||||
pairsByDay,
|
||||
sixtyDaysAgo,
|
||||
|
||||
@@ -328,8 +328,13 @@ export function deriveDoseStatus(
|
||||
* the 4-day rule); everything denser (daily, intraday multi-dose) is
|
||||
* `daily` (minute-scale windows).
|
||||
*/
|
||||
export function doseCadenceFamily(schedule: ComplianceSchedule): DoseCadenceFamily {
|
||||
if (schedule.rollingIntervalDays != null && schedule.rollingIntervalDays >= 2) {
|
||||
export function doseCadenceFamily(
|
||||
schedule: ComplianceSchedule,
|
||||
): DoseCadenceFamily {
|
||||
if (
|
||||
schedule.rollingIntervalDays != null &&
|
||||
schedule.rollingIntervalDays >= 2
|
||||
) {
|
||||
return "weekly";
|
||||
}
|
||||
const rrule = schedule.rrule ?? "";
|
||||
@@ -496,7 +501,9 @@ function rollingIntakeInstants(
|
||||
return events
|
||||
.filter(
|
||||
(e): e is { takenAt: Date; skipped: boolean } =>
|
||||
!e.skipped && e.takenAt !== null && e.takenAt.getTime() <= now.getTime(),
|
||||
!e.skipped &&
|
||||
e.takenAt !== null &&
|
||||
e.takenAt.getTime() <= now.getTime(),
|
||||
)
|
||||
.map((e) => e.takenAt)
|
||||
.sort((a, b) => a.getTime() - b.getTime());
|
||||
@@ -584,7 +591,9 @@ function intakeInstantsAtOrBefore(
|
||||
return intakes
|
||||
.filter(
|
||||
(e): e is { takenAt: Date; skipped: boolean } =>
|
||||
!e.skipped && e.takenAt !== null && e.takenAt.getTime() <= now.getTime(),
|
||||
!e.skipped &&
|
||||
e.takenAt !== null &&
|
||||
e.takenAt.getTime() <= now.getTime(),
|
||||
)
|
||||
.map((e) => e.takenAt)
|
||||
.sort((a, b) => a.getTime() - b.getTime());
|
||||
@@ -658,7 +667,13 @@ export function expectedSlotCountForDay(
|
||||
ctx.scheduleRevisions ?? [],
|
||||
schedules.map((s, i) => toCanonicalSchedule(s, `compliance-daily-${i}`)),
|
||||
(schedule, eraFrom, eraTo) =>
|
||||
expandComplianceOccurrences(schedule, recurrenceCtx, eraFrom, eraTo, retro),
|
||||
expandComplianceOccurrences(
|
||||
schedule,
|
||||
recurrenceCtx,
|
||||
eraFrom,
|
||||
eraTo,
|
||||
retro,
|
||||
),
|
||||
{ oneShot: ctx.oneShot },
|
||||
).length;
|
||||
}
|
||||
@@ -712,7 +727,13 @@ export function expectedSlotsBetween(
|
||||
ctx.scheduleRevisions ?? [],
|
||||
schedules.map((s, i) => toCanonicalSchedule(s, `compliance-slots-${i}`)),
|
||||
(schedule, eraFrom, eraTo) =>
|
||||
expandComplianceOccurrences(schedule, recurrenceCtx, eraFrom, eraTo, retro),
|
||||
expandComplianceOccurrences(
|
||||
schedule,
|
||||
recurrenceCtx,
|
||||
eraFrom,
|
||||
eraTo,
|
||||
retro,
|
||||
),
|
||||
{ oneShot: ctx.oneShot },
|
||||
);
|
||||
}
|
||||
@@ -735,12 +756,13 @@ export const MIN_STABLE_DOSES = 4;
|
||||
* expected doses to mean something, up to a 12-month long window for very
|
||||
* rare meds.
|
||||
*/
|
||||
export const COMPLIANCE_WINDOW_LADDER: ReadonlyArray<readonly [number, number]> =
|
||||
[
|
||||
[7, 30],
|
||||
[30, 90],
|
||||
[90, 365],
|
||||
];
|
||||
export const COMPLIANCE_WINDOW_LADDER: ReadonlyArray<
|
||||
readonly [number, number]
|
||||
> = [
|
||||
[7, 30],
|
||||
[30, 90],
|
||||
[90, 365],
|
||||
];
|
||||
|
||||
/**
|
||||
* v1.8.6 — the day-counts of the two compliance windows a medication's
|
||||
@@ -803,10 +825,7 @@ export function selectComplianceWindows(
|
||||
for (const [shortDays, longDays] of COMPLIANCE_WINDOW_LADDER) {
|
||||
const expectedShort = expected(shortDays);
|
||||
const expectedLong = expected(longDays);
|
||||
if (
|
||||
expectedShort >= MIN_STABLE_DOSES &&
|
||||
expectedLong >= MIN_STABLE_DOSES
|
||||
) {
|
||||
if (expectedShort >= MIN_STABLE_DOSES && expectedLong >= MIN_STABLE_DOSES) {
|
||||
return { shortDays, longDays, expectedShort, expectedLong };
|
||||
}
|
||||
}
|
||||
@@ -886,7 +905,13 @@ export interface ComplianceDisplay {
|
||||
* `missed` the count counting against the rate. The card can render
|
||||
* "26 / 30 · 87%" from `taken` / `expected`.
|
||||
*/
|
||||
short: { rate: number; taken: number; expected: number; missed: number; streak: number };
|
||||
short: {
|
||||
rate: number;
|
||||
taken: number;
|
||||
expected: number;
|
||||
missed: number;
|
||||
streak: number;
|
||||
};
|
||||
/** Compliance percentage + counts over the long window. */
|
||||
long: { rate: number; taken: number; expected: number; missed: number };
|
||||
/**
|
||||
@@ -967,8 +992,7 @@ export function buildCurrentCycle(
|
||||
const recurrenceCtx = toRecurrenceCtx(ctx, "compliance-cycle");
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const allIntakes = intakes ?? [];
|
||||
const lastIntakeAt =
|
||||
lastNonSkippedTakenAt(allIntakes) ?? ctx.lastIntakeAt;
|
||||
const lastIntakeAt = lastNonSkippedTakenAt(allIntakes) ?? ctx.lastIntakeAt;
|
||||
|
||||
// The open cycle's due instant + grace, picked as the SOONEST across every
|
||||
// schedule. Two paths:
|
||||
@@ -999,10 +1023,9 @@ export function buildCurrentCycle(
|
||||
const anchor =
|
||||
lastIntakeAt !== null
|
||||
? new Date(lastIntakeAt.getTime() + n * DAY_MS)
|
||||
: ctx.startsOn ?? ctx.createdAt;
|
||||
: (ctx.startsOn ?? ctx.createdAt);
|
||||
if (ctx.endsOn && anchor.getTime() > ctx.endsOn.getTime()) continue;
|
||||
const graceMs =
|
||||
(canonical.reminderGraceMinutes ?? 60) * 60 * 1000;
|
||||
const graceMs = (canonical.reminderGraceMinutes ?? 60) * 60 * 1000;
|
||||
consider(anchor, new Date(anchor.getTime() + graceMs));
|
||||
continue;
|
||||
}
|
||||
@@ -1032,7 +1055,12 @@ export function buildCurrentCycle(
|
||||
const hasClosedCycles = expectedShort > 0;
|
||||
|
||||
if (dueAt === null || graceUntil === null) {
|
||||
return { state: "none", nextDueAt: null, graceUntil: null, hasClosedCycles };
|
||||
return {
|
||||
state: "none",
|
||||
nextDueAt: null,
|
||||
graceUntil: null,
|
||||
hasClosedCycles,
|
||||
};
|
||||
}
|
||||
|
||||
// Narrow the union-mutated locals for the comparisons below.
|
||||
@@ -1073,10 +1101,16 @@ export function buildComplianceDisplay(
|
||||
const { shortDays, longDays, expectedShort, expectedLong } =
|
||||
selectComplianceWindows(schedules, ctx, { now, intakes: events });
|
||||
|
||||
const short = calculateCompliance(events, schedules, shortDays, ctx.createdAt, {
|
||||
now,
|
||||
medicationContext: ctx,
|
||||
});
|
||||
const short = calculateCompliance(
|
||||
events,
|
||||
schedules,
|
||||
shortDays,
|
||||
ctx.createdAt,
|
||||
{
|
||||
now,
|
||||
medicationContext: ctx,
|
||||
},
|
||||
);
|
||||
const long = calculateCompliance(events, schedules, longDays, ctx.createdAt, {
|
||||
now,
|
||||
medicationContext: ctx,
|
||||
@@ -1468,9 +1502,12 @@ export function calculateCompliance(
|
||||
// legacy timeline tally byte-stable — they have no engine context to mint
|
||||
// bands from.
|
||||
const ledgerCtx = options?.medicationContext;
|
||||
let ledgerCounts:
|
||||
| { taken: number; skipped: number; missed: number; rate: number }
|
||||
| null = null;
|
||||
let ledgerCounts: {
|
||||
taken: number;
|
||||
skipped: number;
|
||||
missed: number;
|
||||
rate: number;
|
||||
} | null = null;
|
||||
if (ledgerCtx) {
|
||||
const tally = tallyComplianceFromLedger(
|
||||
events,
|
||||
@@ -1598,8 +1635,7 @@ export function calculateCompliance(
|
||||
// Skipped doses are excluded from the denominator — they represent a
|
||||
// deliberate user decision rather than a missed dose.
|
||||
const denom = taken + missed;
|
||||
rate =
|
||||
denom > 0 ? Math.min(100, Math.round((taken / denom) * 100)) : 100;
|
||||
rate = denom > 0 ? Math.min(100, Math.round((taken / denom) * 100)) : 100;
|
||||
}
|
||||
|
||||
const totalExpected = taken + skipped + missed;
|
||||
@@ -1726,7 +1762,10 @@ export function buildMedicationComplianceBundle(
|
||||
1,
|
||||
Math.ceil((now.getTime() - effectiveStart.getTime()) / ONE_DAY_MS),
|
||||
);
|
||||
const tally = tallyLedgerRows(ledgerRows, { from: effectiveStart, to: now });
|
||||
const tally = tallyLedgerRows(ledgerRows, {
|
||||
from: effectiveStart,
|
||||
to: now,
|
||||
});
|
||||
const { current: streak } = streaksFromTimeline(
|
||||
timeline,
|
||||
now,
|
||||
@@ -1834,7 +1873,13 @@ export function buildMedicationComplianceBundle(
|
||||
currentDose,
|
||||
};
|
||||
|
||||
return { compliance7, compliance30, complianceDisplay, ledgerRows, ledgerFrom };
|
||||
return {
|
||||
compliance7,
|
||||
compliance30,
|
||||
complianceDisplay,
|
||||
ledgerRows,
|
||||
ledgerFrom,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -174,7 +174,14 @@ export async function computeCorrelationHypothesesFastPath(
|
||||
// reader so the per-type fan-out doesn't re-query the user 3×.
|
||||
const priority = await loadUserSourcePriority(userId);
|
||||
const [sysBuckets, pulseBuckets, weightBuckets] = await Promise.all([
|
||||
readRollupBuckets(userId, "BLOOD_PRESSURE_SYS", "DAY", since, now, priority),
|
||||
readRollupBuckets(
|
||||
userId,
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"DAY",
|
||||
since,
|
||||
now,
|
||||
priority,
|
||||
),
|
||||
readRollupBuckets(userId, "PULSE", "DAY", since, now, priority),
|
||||
readRollupBuckets(userId, "WEIGHT", "DAY", since, now, priority),
|
||||
]);
|
||||
|
||||
@@ -104,4 +104,3 @@ export function pearsonCorrelation(
|
||||
|
||||
return { r, strength, n };
|
||||
}
|
||||
|
||||
@@ -146,7 +146,6 @@ export interface TimeInRangeDistribution {
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/** Variability summary: SD + CV% with the Monnier stability flag. */
|
||||
export interface GlucoseVariability {
|
||||
/** Sample standard deviation (n−1), mg/dL. */
|
||||
@@ -245,7 +244,9 @@ export function glucoseSD(values: number[]): number | null {
|
||||
* Monnier 2017, Diabetes Care 40(7):832-838, DOI 10.2337/dc16-1769;
|
||||
* endorsed in Battelino 2019.
|
||||
*/
|
||||
export function glucoseVariability(values: number[]): GlucoseVariability | null {
|
||||
export function glucoseVariability(
|
||||
values: number[],
|
||||
): GlucoseVariability | null {
|
||||
const sd = glucoseSD(values);
|
||||
if (sd === null) return null;
|
||||
const m = mean(values);
|
||||
@@ -344,9 +345,7 @@ export function bloodGlucoseRiskIndices(
|
||||
* level-1), so the five fractions are NOT mutually exclusive by design.
|
||||
* Battelino 2019, Diabetes Care 42(8):1593-1603, DOI 10.2337/dci19-0028.
|
||||
*/
|
||||
export function timeInRange(
|
||||
values: number[],
|
||||
): TimeInRangeDistribution | null {
|
||||
export function timeInRange(values: number[]): TimeInRangeDistribution | null {
|
||||
const n = values.length;
|
||||
if (n === 0) return null;
|
||||
|
||||
|
||||
@@ -235,7 +235,9 @@ export async function computeUserHealthScoreFastPath(
|
||||
kg: b.mean,
|
||||
}));
|
||||
weightSeriesPrev30d = dayBuckets
|
||||
.filter((b) => b.bucketStart >= prevSince30d && b.bucketStart <= prevUntil)
|
||||
.filter(
|
||||
(b) => b.bucketStart >= prevSince30d && b.bucketStart <= prevUntil,
|
||||
)
|
||||
.map((b) => ({
|
||||
date: b.bucketStart.toISOString(),
|
||||
kg: b.mean,
|
||||
@@ -266,7 +268,9 @@ export async function computeUserHealthScoreFastPath(
|
||||
select: { measuredAt: true, source: true },
|
||||
orderBy: { measuredAt: "asc" },
|
||||
});
|
||||
weightSourcesIn30d = uniqueComponentSources(sourceRows.map((r) => r.source));
|
||||
weightSourcesIn30d = uniqueComponentSources(
|
||||
sourceRows.map((r) => r.source),
|
||||
);
|
||||
latestWeightAsOf = sourceRows.at(-1)?.measuredAt.toISOString() ?? null;
|
||||
} else {
|
||||
// Live fallback — raw rows over the 37-day window. Mirrors the
|
||||
@@ -288,13 +292,13 @@ export async function computeUserHealthScoreFastPath(
|
||||
.filter((r) => r.measuredAt >= prevSince30d && r.measuredAt <= prevUntil)
|
||||
.map((r) => ({ date: r.measuredAt.toISOString(), kg: r.value }));
|
||||
weightSourcesIn30d = uniqueComponentSources(
|
||||
weightRows
|
||||
.filter((r) => r.measuredAt >= since30d)
|
||||
.map((r) => r.source),
|
||||
weightRows.filter((r) => r.measuredAt >= since30d).map((r) => r.source),
|
||||
);
|
||||
latestWeightAsOf =
|
||||
weightRows.filter((r) => r.measuredAt >= since30d).at(-1)?.measuredAt
|
||||
.toISOString() ?? null;
|
||||
weightRows
|
||||
.filter((r) => r.measuredAt >= since30d)
|
||||
.at(-1)
|
||||
?.measuredAt.toISOString() ?? null;
|
||||
}
|
||||
|
||||
// Remaining reads — mood, BP-SYS source attribution, medications +
|
||||
@@ -327,9 +331,7 @@ export async function computeUserHealthScoreFastPath(
|
||||
select: { score: true, moodLoggedAt: true },
|
||||
orderBy: { moodLoggedAt: "asc" },
|
||||
})
|
||||
: Promise.resolve(
|
||||
[] as Array<{ score: number; moodLoggedAt: Date }>,
|
||||
),
|
||||
: Promise.resolve([] as Array<{ score: number; moodLoggedAt: Date }>),
|
||||
prisma.medication.findMany({
|
||||
// v1.16.11 — as-needed (PRN) medications carry no expected doses
|
||||
// and are excluded from every compliance rate: they must
|
||||
|
||||
@@ -78,8 +78,7 @@ export function buildHealthScoreBpInputs(
|
||||
// 90-day window is itself sparse so a deep-history account survives a quiet
|
||||
// quarter, but never grade off a sub-floor 90-day sample.
|
||||
const last90Sufficient =
|
||||
current.last90Days !== null &&
|
||||
isWindowSufficient(current.last90Days.pairs);
|
||||
current.last90Days !== null && isWindowSufficient(current.last90Days.pairs);
|
||||
const bpInTargetPct = last90Sufficient
|
||||
? (current.last90Days?.pct ?? null)
|
||||
: (current.allTime?.pct ?? null);
|
||||
|
||||
@@ -267,8 +267,7 @@ function bareWeightTrendScore(
|
||||
// 25-point span keeps the unfavourable floor honest (a steep sustained
|
||||
// gain reads "concerning", not "failing") while the favourable ceiling
|
||||
// reaches the full 100.
|
||||
const score =
|
||||
slope < 0 ? 75 + 25 * normalised : 75 - 50 * normalised;
|
||||
const score = slope < 0 ? 75 + 25 * normalised : 75 - 50 * normalised;
|
||||
return Math.round(Math.max(0, Math.min(100, score)));
|
||||
}
|
||||
|
||||
@@ -502,8 +501,7 @@ function redistribute(
|
||||
const source = resolveSourceLabel(value !== null, attr.sources);
|
||||
// When a component is `none`, fall back to the window-end anchor so
|
||||
// the UI can still render "as of <today>" without inventing a date.
|
||||
const asOf =
|
||||
source === "none" ? windowEndAt : (attr.asOf ?? windowEndAt);
|
||||
const asOf = source === "none" ? windowEndAt : (attr.asOf ?? windowEndAt);
|
||||
return {
|
||||
value,
|
||||
weight: weightFor(key),
|
||||
|
||||
@@ -67,7 +67,9 @@ export interface MergedDashboardAnalytics {
|
||||
glucoseByContext: Record<string, unknown> | undefined;
|
||||
}
|
||||
|
||||
function hasContent(record: Record<string, unknown> | null | undefined): boolean {
|
||||
function hasContent(
|
||||
record: Record<string, unknown> | null | undefined,
|
||||
): boolean {
|
||||
return record != null && Object.keys(record).length > 0;
|
||||
}
|
||||
|
||||
@@ -91,8 +93,8 @@ export function mergeSlimAndThickAnalytics(
|
||||
const summaries: Record<string, DataSummary> = slimSummariesHaveContent
|
||||
? (slim!.summaries as Record<string, DataSummary>)
|
||||
: ((thick?.summaries as Record<string, DataSummary> | null | undefined) ??
|
||||
(slim?.summaries as Record<string, DataSummary> | null | undefined) ??
|
||||
{});
|
||||
(slim?.summaries as Record<string, DataSummary> | null | undefined) ??
|
||||
{});
|
||||
|
||||
const slimFreshnessHasContent = hasContent(slim?.lastSeenByType ?? null);
|
||||
const lastSeenByType:
|
||||
|
||||
@@ -121,7 +121,11 @@ export async function computeRangeDelta(
|
||||
const windowDays = rangeWindowDays(range);
|
||||
// Read the full 2N span in one go so the current and previous halves share
|
||||
// a granularity; the router picks the coarsest tier that resolves 2N.
|
||||
const resolved = await readBestGranularityRollups(userId, type, windowDays * 2);
|
||||
const resolved = await readBestGranularityRollups(
|
||||
userId,
|
||||
type,
|
||||
windowDays * 2,
|
||||
);
|
||||
if (!resolved) {
|
||||
const empty: WindowAggregate = {
|
||||
count: 0,
|
||||
@@ -140,7 +144,11 @@ export async function computeRangeDelta(
|
||||
deltaPct: null,
|
||||
};
|
||||
}
|
||||
const { current, previous } = sliceWindowDelta(resolved.rows, windowDays, now);
|
||||
const { current, previous } = sliceWindowDelta(
|
||||
resolved.rows,
|
||||
windowDays,
|
||||
now,
|
||||
);
|
||||
const { delta, deltaPct } = composeDelta(current, previous);
|
||||
return {
|
||||
range,
|
||||
|
||||
@@ -130,10 +130,7 @@ export function resolveRestingPulseSeries(input: {
|
||||
);
|
||||
return { series, which: "resting" };
|
||||
}
|
||||
const proxy = deriveRestingProxyFromPulse(
|
||||
input.pulseSamples,
|
||||
input.dayKeyOf,
|
||||
);
|
||||
const proxy = deriveRestingProxyFromPulse(input.pulseSamples, input.dayKeyOf);
|
||||
if (proxy.length > 0) return { series: proxy, which: "proxy" };
|
||||
return { series: [], which: "none" };
|
||||
}
|
||||
@@ -137,7 +137,9 @@ export function pickCanonicalSourceRows<T extends SourcePickerRow>(
|
||||
// rather than once per row. Resolution itself lives in
|
||||
// `getDeviceTypeLadder` — the cache is the only extra layer here.
|
||||
const ladderCache = new Map<string, readonly DeviceType[]>();
|
||||
function resolveLadder(rowType: MeasurementType | null | undefined): readonly DeviceType[] {
|
||||
function resolveLadder(
|
||||
rowType: MeasurementType | null | undefined,
|
||||
): readonly DeviceType[] {
|
||||
const cacheKey = rowType ?? "__default__";
|
||||
let ladder = ladderCache.get(cacheKey);
|
||||
if (!ladder) {
|
||||
|
||||
@@ -362,7 +362,11 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
|
||||
// the unqualified rollup + inner-subquery columns, `m."…"` for the latest
|
||||
// read's outer alias.
|
||||
const priorityJson = await loadUserSourcePriority(userId);
|
||||
const rankUnqualified = buildSourceRankCase(priorityJson, '"type"', '"source"');
|
||||
const rankUnqualified = buildSourceRankCase(
|
||||
priorityJson,
|
||||
'"type"',
|
||||
'"source"',
|
||||
);
|
||||
const rankM = buildSourceRankCase(priorityJson, 'm."type"', 'm."source"');
|
||||
|
||||
const [narrows, latests, dayBuckets] = await Promise.all([
|
||||
@@ -628,7 +632,11 @@ async function computeFromLiveAggregate(
|
||||
// parity). `canonicalMeasurementsFrom` swaps `FROM measurements m` for a
|
||||
// canonical-source-filtered subquery; the latest read uses the ladder rank.
|
||||
const priorityJson = await loadUserSourcePriority(userId);
|
||||
const rankUnqualified = buildSourceRankCase(priorityJson, '"type"', '"source"');
|
||||
const rankUnqualified = buildSourceRankCase(
|
||||
priorityJson,
|
||||
'"type"',
|
||||
'"source"',
|
||||
);
|
||||
const rankM = buildSourceRankCase(priorityJson, 'm."type"', 'm."source"');
|
||||
|
||||
const [allTime, windowed, latests] = await Promise.all([
|
||||
|
||||
@@ -66,9 +66,7 @@ function isTolerableRequestProbeError(err: unknown): boolean {
|
||||
// Covers both modern `Cannot read properties of undefined
|
||||
// (reading 'X')` and the older `Cannot read property 'X' of
|
||||
// undefined` wordings.
|
||||
/Cannot read propert(?:y|ies)\b.*\bof (?:undefined|null)\b/.test(
|
||||
msg,
|
||||
)
|
||||
/Cannot read propert(?:y|ies)\b.*\bof (?:undefined|null)\b/.test(msg)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -144,11 +142,8 @@ export function apiHandler<T extends (...args: any[]) => Promise<Response>>(
|
||||
route: url.pathname,
|
||||
status: 200,
|
||||
user_agent:
|
||||
safeRequestProp(
|
||||
request,
|
||||
(r) => r.headers.get("user-agent"),
|
||||
null,
|
||||
) ?? undefined,
|
||||
safeRequestProp(request, (r) => r.headers.get("user-agent"), null) ??
|
||||
undefined,
|
||||
ip:
|
||||
safeRequestProp(
|
||||
request,
|
||||
|
||||
@@ -188,11 +188,7 @@ export function returnAllZodIssues(
|
||||
* key is *not* echoed into the JSON body — it is consumed by the
|
||||
* NextResponse constructor and stripped from the meta envelope.
|
||||
*/
|
||||
export function apiError(
|
||||
message: string,
|
||||
status = 400,
|
||||
meta?: ErrorMeta,
|
||||
) {
|
||||
export function apiError(message: string, status = 400, meta?: ErrorMeta) {
|
||||
return buildJsonErrorResponse({ data: null, error: message }, status, meta);
|
||||
}
|
||||
|
||||
@@ -229,10 +225,7 @@ export async function safeJson<T = unknown>(
|
||||
}
|
||||
if (raw.length > opts.maxBytes) {
|
||||
return {
|
||||
error: apiError(
|
||||
`Request body exceeds ${opts.maxBytes} bytes`,
|
||||
413,
|
||||
),
|
||||
error: apiError(`Request body exceeds ${opts.maxBytes} bytes`, 413),
|
||||
};
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -315,7 +315,9 @@ describe("verb helpers", () => {
|
||||
await apiPost(
|
||||
"/api/foo",
|
||||
{ a: 1 },
|
||||
{ headers: new Headers({ "Content-Type": "application/json-patch+json" }) },
|
||||
{
|
||||
headers: new Headers({ "Content-Type": "application/json-patch+json" }),
|
||||
},
|
||||
);
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
|
||||
@@ -2,7 +2,11 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import { readError } from "../read-error";
|
||||
|
||||
function makeResponse(body: unknown, status: number, opts?: { invalidJson?: boolean }): Response {
|
||||
function makeResponse(
|
||||
body: unknown,
|
||||
status: number,
|
||||
opts?: { invalidJson?: boolean },
|
||||
): Response {
|
||||
if (opts?.invalidJson) {
|
||||
return new Response("<<not json>>", {
|
||||
status,
|
||||
|
||||
@@ -58,11 +58,7 @@ export class ApiError extends Error {
|
||||
/** `meta` object of the error envelope, when the body carried one. */
|
||||
readonly meta: Record<string, unknown> | undefined;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
status: number,
|
||||
meta?: Record<string, unknown>,
|
||||
) {
|
||||
constructor(message: string, status: number, meta?: Record<string, unknown>) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
|
||||
@@ -118,7 +118,9 @@ describe("recordInviteConsumer", () => {
|
||||
|
||||
it("swallows failures — the use is already counted", async () => {
|
||||
transaction.mockRejectedValueOnce(new Error("boom"));
|
||||
await expect(recordInviteConsumer("inv1", "user1")).resolves.toBeUndefined();
|
||||
await expect(
|
||||
recordInviteConsumer("inv1", "user1"),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -127,7 +127,10 @@ describe("isCookielessNativeCaller (M-3 refresh-token issuance gate)", () => {
|
||||
it("true for a native caller whose cookie header lacks the session cookie", () => {
|
||||
expect(
|
||||
isCookielessNativeCaller(
|
||||
h({ "x-client-type": "native", cookie: "other=1; hl_onboarding=pending" }),
|
||||
h({
|
||||
"x-client-type": "native",
|
||||
cookie: "other=1; hl_onboarding=pending",
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
@@ -185,4 +185,3 @@ export async function destroyAllSessions(userId: string): Promise<void> {
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
+3
-9
@@ -35,11 +35,7 @@ export function detectAvatarMimeType(buffer: Buffer): AvatarMime | null {
|
||||
if (buffer.length < 12) return null;
|
||||
|
||||
// JPEG — FF D8 FF
|
||||
if (
|
||||
buffer[0] === 0xff &&
|
||||
buffer[1] === 0xd8 &&
|
||||
buffer[2] === 0xff
|
||||
) {
|
||||
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
|
||||
return "image/jpeg";
|
||||
}
|
||||
|
||||
@@ -195,10 +191,8 @@ function readWebpDimensions(buffer: Buffer): AvatarDimensions | null {
|
||||
// width-1 + 3-byte canvas height-1 (all little-endian, padded
|
||||
// to 24 bits).
|
||||
if (buffer.length < 30) return null;
|
||||
const widthMinusOne =
|
||||
buffer[24] | (buffer[25] << 8) | (buffer[26] << 16);
|
||||
const heightMinusOne =
|
||||
buffer[27] | (buffer[28] << 8) | (buffer[29] << 16);
|
||||
const widthMinusOne = buffer[24] | (buffer[25] << 8) | (buffer[26] << 16);
|
||||
const heightMinusOne = buffer[27] | (buffer[28] << 8) | (buffer[29] << 16);
|
||||
const width = widthMinusOne + 1;
|
||||
const height = heightMinusOne + 1;
|
||||
if (width <= 0 || height <= 0) return null;
|
||||
|
||||
@@ -69,9 +69,7 @@ export function computeWindowStats(
|
||||
const sorted = [...finite].sort((a, b) => a - b);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
const median =
|
||||
sorted.length % 2 === 0
|
||||
? (sorted[mid - 1] + sorted[mid]) / 2
|
||||
: sorted[mid];
|
||||
sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
|
||||
|
||||
return { count: finite.length, min, max, median, mean };
|
||||
}
|
||||
@@ -58,7 +58,9 @@ describe("resolveShareGateState", () => {
|
||||
});
|
||||
|
||||
it("returns null for an expired link", async () => {
|
||||
findUnique.mockResolvedValue(row({ expiresAt: new Date(Date.now() - 1000) }));
|
||||
findUnique.mockResolvedValue(
|
||||
row({ expiresAt: new Date(Date.now() - 1000) }),
|
||||
);
|
||||
expect(await resolveShareGateState(VALID_TOKEN)).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
Loaded 100 of 1000 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user