Merge branch 'v143/fixui' into release/v1.43.0

This commit is contained in:
Marc-André Bombeck committed 2026-10-16 06:04:00 +02:00
commit d3f6d2d7c7
38 files changed
+1260 -127

No files matched your search

+20 -2
View File
@@ -46823,8 +46823,11 @@ components:
- up-bad
- neutral
- hold
description: "How a change counts: `up-good` a rise is progress, `up-bad` a fall is progress, `hold` the value is inside
its band so staying is progress, `neutral` no judgement."
description: "How a change counts: `up-good` a rise is progress, `up-bad` a fall is progress, `hold` staying is progress
and a move either way is no setback, `neutral` no judgement. `hold` covers a value inside its band, a blood
pressure, resting pulse or body fat below a profile-default band (a default's lower edge is no goal; below
the person's own target a rise stays `up-good`), and steps or oxygen saturation above their band (more is
never worse)."
targetPosition:
anyOf:
- type: string
@@ -75953,12 +75956,27 @@ components:
enum:
- total
- supplemental
form:
anyOf:
- type: string
enum:
- retinol
- folic_acid
- magnesium_salts
- alpha_tocopherol
- type: "null"
description: "v1.43 — the chemical form the level is set for, where narrower than the nutrient: `retinol` (vitamin A:
preformed retinol only, beta-carotene does not count), `folic_acid`, `magnesium_salts` (readily dissociable
salts in supplements), `alpha_tocopherol`. `null` where the level covers the nutrient as such. Phrase it in
the reader's language; skip an unknown value."
source:
type: string
description: The bare citation (EFSA overview and the opinion it rests on), not prose. Every level here is the adult one.
required:
- value
- unit
- scope
- form
- source
additionalProperties: false
description: The tolerable upper intake level, with the unit, whether it applies to the total intake or to the
+8 -2
View File
@@ -11873,8 +11873,14 @@
"emptyTitle": "Noch keine Mikronährstoffdaten synchronisiert",
"loadError": "Deine Nährstoffdaten konnten nicht geladen werden",
"sourcePart": "{source} {value} {unit}",
"aboveUpperLimit": "Über der tolerierbaren Höchstmenge von {value} {unit} pro Tag ({source}).",
"aboveUpperLimitSupplemental": "Der Anteil aus Präparaten liegt über der tolerierbaren Höchstmenge von {value} {unit} pro Tag für Präparate ({source})."
"aboveUpperLimit": "Über der tolerierbaren Höchstmenge für Erwachsene von {value} {unit} pro Tag ({source}).",
"aboveUpperLimitSupplemental": "Der Anteil aus Präparaten liegt über der tolerierbaren Höchstmenge für Erwachsene von {value} {unit} pro Tag für Präparate ({source}).",
"upperLimitForm": {
"retinol": "Die Höchstmenge gilt nur für vorgeformtes Vitamin A (Retinol): Stammt die Menge aus Beta-Carotin, gilt sie nicht.",
"folic_acid": "Die Höchstmenge gilt für Folsäure aus Präparaten und angereicherten Lebensmitteln, nicht für das Folat in gewöhnlicher Nahrung.",
"magnesium_salts": "Die Höchstmenge gilt für leicht lösliche Magnesiumsalze in Präparaten, nicht für das Magnesium aus der Nahrung.",
"alpha_tocopherol": "Die Höchstmenge ist für Alpha-Tocopherol festgelegt."
}
},
"sparseNote": "Ein niedriger Wert bedeutet hier meist lückenhafte Erfassung, keinen Mangel. Die Tagessummen aus Apple Health spiegeln wider, was deine Apps erfasst haben, nicht die gesamte Nahrungsaufnahme.",
"units": {
+8 -2
View File
@@ -11873,8 +11873,14 @@
"emptyTitle": "No micronutrient data synced yet",
"loadError": "Your nutrient data could not be loaded",
"sourcePart": "{source} {value} {unit}",
"aboveUpperLimit": "Above the tolerable upper intake level of {value} {unit} a day ({source}).",
"aboveUpperLimitSupplemental": "The supplement share is above the tolerable upper intake level of {value} {unit} a day for supplements ({source})."
"aboveUpperLimit": "Above the tolerable upper intake level for adults of {value} {unit} a day ({source}).",
"aboveUpperLimitSupplemental": "The supplement share is above the tolerable upper intake level for adults of {value} {unit} a day for supplements ({source}).",
"upperLimitForm": {
"retinol": "The level covers preformed vitamin A (retinol) only: if the amount comes from beta-carotene, it does not apply.",
"folic_acid": "The level covers folic acid from supplements and fortified foods, not the folate in ordinary food.",
"magnesium_salts": "The level covers readily dissociable magnesium salts in supplements, not the magnesium in food.",
"alpha_tocopherol": "The level is set for alpha-tocopherol."
}
},
"sparseNote": "A low number here usually means sparse logging, not a deficiency. Apple Health day totals reflect what your apps logged, not total dietary intake.",
"units": {
+8 -2
View File
@@ -11873,8 +11873,14 @@
"emptyTitle": "Aún no hay datos de micronutrientes sincronizados",
"loadError": "No se pudieron cargar tus datos de nutrientes",
"sourcePart": "{source} {value} {unit}",
"aboveUpperLimit": "Por encima del nivel máximo tolerable de {value} {unit} al día ({source}).",
"aboveUpperLimitSupplemental": "La parte de los suplementos supera el nivel máximo tolerable de {value} {unit} al día para suplementos ({source})."
"aboveUpperLimit": "Por encima del nivel máximo tolerable para adultos de {value} {unit} al día ({source}).",
"aboveUpperLimitSupplemental": "La parte de los suplementos supera el nivel máximo tolerable para adultos de {value} {unit} al día para suplementos ({source}).",
"upperLimitForm": {
"retinol": "El nivel solo se aplica a la vitamina A preformada (retinol): si la cantidad procede de betacaroteno, no se aplica.",
"folic_acid": "El nivel se aplica al ácido fólico de suplementos y alimentos enriquecidos, no al folato de los alimentos habituales.",
"magnesium_salts": "El nivel se aplica a las sales de magnesio fácilmente disociables de los suplementos, no al magnesio de los alimentos.",
"alpha_tocopherol": "El nivel está fijado para el alfa-tocoferol."
}
},
"sparseNote": "Un valor bajo aquí suele significar un registro escaso, no una deficiencia: los totales diarios de Apple Health reflejan lo que registraron tus apps, no la ingesta dietética total.",
"units": {
+8 -2
View File
@@ -11873,8 +11873,14 @@
"emptyTitle": "Aucune donnée de micronutriments synchronisée pour l'instant",
"loadError": "Tes données de nutriments n'ont pas pu être chargées",
"sourcePart": "{source} {value} {unit}",
"aboveUpperLimit": "Au-dessus de la limite supérieure de sécurité de {value} {unit} par jour ({source}).",
"aboveUpperLimitSupplemental": "La part des compléments dépasse la limite supérieure de sécurité de {value} {unit} par jour pour les compléments ({source})."
"aboveUpperLimit": "Au-dessus de la limite supérieure de sécurité pour les adultes de {value} {unit} par jour ({source}).",
"aboveUpperLimitSupplemental": "La part des compléments dépasse la limite supérieure de sécurité pour les adultes de {value} {unit} par jour pour les compléments ({source}).",
"upperLimitForm": {
"retinol": "La limite ne concerne que la vitamine A préformée (rétinol) : si la quantité provient du bêta-carotène, elle ne s'applique pas.",
"folic_acid": "La limite concerne l'acide folique des compléments et des aliments enrichis, pas les folates de l'alimentation courante.",
"magnesium_salts": "La limite concerne les sels de magnésium facilement dissociables des compléments, pas le magnésium des aliments.",
"alpha_tocopherol": "La limite est fixée pour l'alpha-tocophérol."
}
},
"sparseNote": "Une valeur basse ici signifie généralement un suivi partiel, pas une carence : les totaux journaliers Apple Health reflètent ce que vos applis ont enregistré, pas l'apport alimentaire total.",
"units": {
+8 -2
View File
@@ -11873,8 +11873,14 @@
"emptyTitle": "Nessun dato sui micronutrienti ancora sincronizzato",
"loadError": "Non è stato possibile caricare i tuoi dati sui nutrienti",
"sourcePart": "{source} {value} {unit}",
"aboveUpperLimit": "Sopra il livello massimo tollerabile di {value} {unit} al giorno ({source}).",
"aboveUpperLimitSupplemental": "La quota degli integratori supera il livello massimo tollerabile di {value} {unit} al giorno per gli integratori ({source})."
"aboveUpperLimit": "Sopra il livello massimo tollerabile per gli adulti di {value} {unit} al giorno ({source}).",
"aboveUpperLimitSupplemental": "La quota degli integratori supera il livello massimo tollerabile per gli adulti di {value} {unit} al giorno per gli integratori ({source}).",
"upperLimitForm": {
"retinol": "Il livello vale solo per la vitamina A preformata (retinolo): se la quantità proviene dal beta-carotene, non si applica.",
"folic_acid": "Il livello vale per l'acido folico da integratori e alimenti arricchiti, non per i folati del cibo comune.",
"magnesium_salts": "Il livello vale per i sali di magnesio facilmente dissociabili negli integratori, non per il magnesio degli alimenti.",
"alpha_tocopherol": "Il livello è fissato per l'alfa-tocoferolo."
}
},
"sparseNote": "Un valore basso qui di solito indica una registrazione poco frequente, non una carenza: i totali giornalieri di Apple Health riflettono ciò che le tue app hanno registrato, non l'apporto alimentare totale.",
"units": {
+8 -2
View File
@@ -11873,8 +11873,14 @@
"emptyTitle": "아직 동기화된 미량영양소 데이터가 없어요",
"loadError": "영양소 데이터를 불러오지 못했어요",
"sourcePart": "{source} {value} {unit}",
"aboveUpperLimit": "하루 상한 섭취량 {value} {unit}을(를) 넘었어요 ({source}).",
"aboveUpperLimitSupplemental": "보충제로 섭취한 양이 보충제 기준 하루 상한 섭취량 {value} {unit}을(를) 넘었어요 ({source})."
"aboveUpperLimit": "성인 하루 상한 섭취량 {value} {unit}을(를) 넘었어요 ({source}).",
"aboveUpperLimitSupplemental": "보충제로 섭취한 양이 성인 보충제 기준 하루 상한 섭취량 {value} {unit}을(를) 넘었어요 ({source}).",
"upperLimitForm": {
"retinol": "이 상한은 기성 비타민 A(레티놀)에만 적용돼요. 베타카로틴에서 온 양이라면 적용되지 않아요.",
"folic_acid": "이 상한은 보충제와 강화식품의 엽산에 적용되며, 일반 음식의 엽산염에는 적용되지 않아요.",
"magnesium_salts": "이 상한은 보충제의 쉽게 해리되는 마그네슘염에 적용되며, 음식의 마그네슘에는 적용되지 않아요.",
"alpha_tocopherol": "이 상한은 알파토코페롤 기준이에요."
}
},
"sparseNote": "여기 수치가 낮은 건 보통 결핍이 아니라 기록이 드물다는 뜻이에요. Apple Health의 하루 합계는 앱이 기록한 양만 반영하고, 전체 식이 섭취량은 아니에요.",
"units": {
+8 -2
View File
@@ -11873,8 +11873,14 @@
"emptyTitle": "Brak jeszcze zsynchronizowanych danych o mikroskładnikach",
"loadError": "Nie udało się wczytać danych o składnikach odżywczych",
"sourcePart": "{source} {value} {unit}",
"aboveUpperLimit": "Powyżej tolerowanego górnego poziomu spożycia {value} {unit} dziennie ({source}).",
"aboveUpperLimitSupplemental": "Część z suplementów przekracza tolerowany górny poziom {value} {unit} dziennie dla suplementów ({source})."
"aboveUpperLimit": "Powyżej tolerowanego górnego poziomu spożycia dla dorosłych {value} {unit} dziennie ({source}).",
"aboveUpperLimitSupplemental": "Część z suplementów przekracza tolerowany górny poziom dla dorosłych {value} {unit} dziennie dla suplementów ({source}).",
"upperLimitForm": {
"retinol": "Poziom dotyczy tylko gotowej witaminy A (retinolu): jeśli ilość pochodzi z beta-karotenu, nie ma zastosowania.",
"folic_acid": "Poziom dotyczy kwasu foliowego z suplementów i żywności wzbogacanej, nie folianów ze zwykłego jedzenia.",
"magnesium_salts": "Poziom dotyczy łatwo dysocjujących soli magnezu w suplementach, nie magnezu z jedzenia.",
"alpha_tocopherol": "Poziom ustalono dla alfa-tokoferolu."
}
},
"sparseNote": "Niska wartość zwykle oznacza tu rzadkie zapisywanie, a nie niedobór. Dzienne sumy z Apple Health odzwierciedlają to, co zapisały twoje aplikacje, a nie całkowite spożycie w diecie.",
"units": {
@@ -292,11 +292,19 @@ describe("resolved-field consumer guard", () => {
/**
* v1.43 (ios #116 items 3, 4, 5, 6, 9; #77) — the fields the server resolves
* for the native client reach the web as well, so the two clients render the
* same answer. Each field names the web module that reads it, by a pattern
* over that module's code. A field the web has no screen for names the web
* twin it is resolved alongside (the same resolver, published where the web
* reads it) and why; the twin's reader is checked the same way.
* for the native client either reach the web, or say why they do not.
*
* The inventory is not hand-kept: every response property in the OpenAPI
* route modules typed by one of the resolved-verdict schemas
* (`src/lib/openapi/routes/trend-verdict.ts`) is discovered, and each must be
* claimed by exactly one entry below. An entry either names the web module
* that reads the field, by a pattern that names the field itself, or names
* the field iOS-only with the reason, and where the reason is "the web reads
* the same answer elsewhere", the server modules that must share the
* resolver for that to stay true. A new resolved field with neither fails.
*
* Entries without a discovered property (`VITALS_BASELINE` sits in an open
* record, `lastBackfillAt` is a plain timestamp) carry a web reader by hand.
*
* The rebuild side of the same rule (a client deciding a polarity or drawing
* a threshold of its own) is `client-threshold-literal-guard.test.ts`.
@@ -306,16 +314,39 @@ interface WebReader {
pattern: RegExp;
}
interface ResolvedFieldReader {
interface ResolvedFieldEntry {
field: string;
readers: WebReader[];
/** Set when the web reads a twin rather than this route's field. */
twin?: string;
/** Discovered properties this entry claims: `<route module>#<property>`. */
props: string[];
/** The web modules that read the field. */
readers?: WebReader[];
/** Why the web has no reader; required when `readers` is absent. */
iosOnly?: {
reason: string;
/**
* When the reason is that the web reads the same answer from another
* route: every file here must call the resolver both answers come from.
*/
sameResolver?: { pattern: RegExp; files: string[] };
};
}
const V143_FIELDS: ResolvedFieldReader[] = [
const ROUTES_DIR = "src/lib/openapi/routes";
const RESOLVED_SCHEMAS = [
"trendVerdict",
"trendSentiment",
"seriesChange",
"seriesReferenceLine",
"recordBenchmark",
"moodPattern",
"cycleRestoredStart",
];
const VERDICT_RESOLVER = /\bresolve(?:TrendVerdict|SummaryVerdicts)\(/;
const V143_FIELDS: ResolvedFieldEntry[] = [
{
field: "GET /api/dashboard/snapshot tiles.directions",
props: ["insights/schemas.ts#directions"],
readers: [
{ file: "src/app/page-client.tsx", pattern: /tiles\.directions/ },
],
@@ -323,18 +354,40 @@ const V143_FIELDS: ResolvedFieldReader[] = [
{
field:
"GET /api/dashboard/summary DashboardMetricCard.direction / trendSentiment",
twin: "the summary route is the native client's dashboard; the web dashboard reads the same `resolveTrendVerdict` answer from the snapshot's `tiles.directions`",
readers: [{ file: "src/app/page-client.tsx", pattern: /directionOf\(/ }],
props: ["dashboard.ts#direction", "dashboard.ts#trendSentiment"],
iosOnly: {
reason:
"the summary route is the native client's dashboard; the web dashboard colours its tiles from the snapshot's `tiles.directions`, resolved by the same verdict resolver",
sameResolver: {
pattern: VERDICT_RESOLVER,
files: [
"src/app/api/dashboard/summary/route.ts",
"src/lib/dashboard/snapshot.ts",
],
},
},
},
{
field: "GET /api/insights/targets TargetItem.direction / trendSentiment",
twin: "the web target panels show the band, not a change; the change they would colour is the dashboard tile's, read from `tiles.directions`",
readers: [
{ file: "src/app/page-client.tsx", pattern: /tileDirections\?\.\[key\]/ },
props: [
"insights/schemas.ts#direction",
"insights/schemas.ts#trendSentiment",
],
iosOnly: {
reason:
"the web target panels show the band and where the value sits, never a change, so there is nothing to colour; the native targets page colours the change, placed on the dashboard's seven-day basis",
sameResolver: {
pattern: VERDICT_RESOLVER,
files: [
"src/lib/targets/build-response.ts",
"src/lib/dashboard/snapshot.ts",
],
},
},
},
{
field: "GET /api/insights/derived VITALS_BASELINE value.direction",
props: [],
readers: [
{
file: "src/components/insights/derived/vitals-dashboard.tsx",
@@ -344,11 +397,27 @@ const V143_FIELDS: ResolvedFieldReader[] = [
},
{
field: "GET /api/measurements/series direction / change / referenceLines",
twin: "the web charts read the series-batch and draw the person's bands from the snapshot's `targetBands`; the series read is the native chart source",
readers: [{ file: "src/app/page-client.tsx", pattern: /targetBands/ }],
props: [
"measurements.ts#direction",
"measurements.ts#change",
"measurements.ts#referenceLines",
"trend-verdict.ts#sentiment",
],
iosOnly: {
reason:
"the web charts are fed by `/api/measurements/series-batch`, which carries no verdict, change or reference line; the web colours a chart's change from the tile verdict in `tiles.directions` and draws the person's bands from the snapshot's `targetBands`. The single-series read is the native chart source.",
sameResolver: {
pattern: VERDICT_RESOLVER,
files: [
"src/app/api/measurements/series/route.ts",
"src/lib/dashboard/snapshot.ts",
],
},
},
},
{
field: "GET /api/personal-records benchmark",
props: ["awards.ts#benchmark"],
readers: [
{
file: "src/components/insights/personal-record-badge.tsx",
@@ -358,6 +427,7 @@ const V143_FIELDS: ResolvedFieldReader[] = [
},
{
field: "GET /api/mood/insights patterns",
props: ["mood.ts#patterns"],
readers: [
{
file: "src/components/insights/mood/mood-insights-breakdowns.tsx",
@@ -371,6 +441,7 @@ const V143_FIELDS: ResolvedFieldReader[] = [
},
{
field: "POST /api/cycle/period absorbed",
props: ["cycle.ts#absorbed"],
readers: [
{
file: "src/components/cycle/use-cycle.ts",
@@ -380,6 +451,7 @@ const V143_FIELDS: ResolvedFieldReader[] = [
},
{
field: "DELETE /api/cycle/day-logs/{id} (Prefer) and PATCH restored",
props: ["cycle.ts#restored", "trend-verdict.ts#restored"],
readers: [
{
file: "src/components/cycle/use-cycle.ts",
@@ -393,6 +465,7 @@ const V143_FIELDS: ResolvedFieldReader[] = [
},
{
field: "GET /api/integrations/healthkit metricFreshness[].lastBackfillAt",
props: [],
readers: [
{
file: "src/components/settings/integrations/apple-health-card.tsx",
@@ -402,20 +475,108 @@ const V143_FIELDS: ResolvedFieldReader[] = [
},
];
describe("v1.43 resolved fields reach the web", () => {
/**
* Every response property typed by a resolved-verdict schema, as
* `<route module>#<property>`: `name: schema`, `name: z.array(schema)` and
* `name: z.record(z.string(), schema)`, across line breaks. The schema
* definitions themselves count too (`seriesChange.sentiment`,
* `cycleDeleteOutcome.restored`), since they reach the wire nested.
*/
function discoverResolvedProps(
sources: ReadonlyArray<{ rel: string; code: string }>,
): string[] {
const names = RESOLVED_SCHEMAS.join("|");
const prop = new RegExp(
String.raw`(\w+)\s*:\s*(?:z\s*\.\s*(?:array|record)\(\s*(?:z\s*\.\s*string\(\)\s*,\s*)?)?(?:${names})\b`,
"g",
);
const out: string[] = [];
for (const { rel, code } of sources) {
for (const match of code.matchAll(prop)) out.push(`${rel}#${match[1]}`);
}
return out;
}
function routeSources(): Array<{ rel: string; code: string }> {
return walkSourceFiles(join(ROOT, ROUTES_DIR), { floor: 20 })
.filter((file) => !/__tests__|\.test\.ts$/.test(file))
.map((file) => ({
rel: file,
code: stripComments(readFileSync(join(ROOT, ROUTES_DIR, file), "utf8")),
}));
}
function unescaped(pattern: RegExp): string {
return pattern.source.replace(/\\/g, "");
}
describe("v1.43 resolved fields reach the web, or say why not", () => {
const discovered = discoverResolvedProps(routeSources());
it("discovers the resolved fields from the route modules", () => {
// A matcher that stopped matching would let every field go unclaimed
// without anyone noticing; the inventory has twelve today.
expect(discovered.length).toBeGreaterThanOrEqual(12);
expect(
discoverResolvedProps([
{
rel: "x.ts",
code: "a: trendVerdict.nullable(), b: z\n .array(moodPattern), c: z.record(z.string(), trendVerdict)",
},
]),
).toEqual(["x.ts#a", "x.ts#b", "x.ts#c"]);
});
it("claims every discovered field exactly once, and nothing else", () => {
const claimed = V143_FIELDS.flatMap((entry) => entry.props);
const unclaimed = discovered.filter((key) => !claimed.includes(key));
expect(
unclaimed,
"a resolved field has no web reader and no iOS-only reason: add an entry",
).toEqual([]);
const stale = claimed.filter((key) => !discovered.includes(key));
expect(stale, "an entry claims a field the routes no longer serve").toEqual(
[],
);
expect(new Set(claimed).size).toBe(claimed.length);
});
for (const entry of V143_FIELDS) {
it(`${entry.field} has a web reader${entry.twin ? " through its twin" : ""}`, () => {
expect(entry.readers.length).toBeGreaterThan(0);
if (entry.twin !== undefined)
expect(entry.twin.length).toBeGreaterThan(40);
for (const reader of entry.readers) {
const code = stripComments(
readFileSync(join(ROOT, reader.file), "utf8"),
);
expect(
reader.pattern.test(code),
`${reader.file} no longer reads ${entry.field} (${reader.pattern})`,
).toBe(true);
it(`${entry.field} ${entry.readers ? "has a web reader" : "is iOS-only for a stated reason"}`, () => {
expect(
(entry.readers ? 1 : 0) + (entry.iosOnly ? 1 : 0),
"an entry has a web reader or an iOS-only reason, not both or neither",
).toBe(1);
if (entry.readers) {
expect(entry.readers.length).toBeGreaterThan(0);
// The reader must read the field itself, not something beside it.
for (const key of entry.props) {
const name = key.split("#")[1]!;
expect(
entry.readers.some((reader) =>
unescaped(reader.pattern).includes(name),
),
`no reader pattern names \`${name}\``,
).toBe(true);
}
for (const reader of entry.readers) {
const code = stripComments(
readFileSync(join(ROOT, reader.file), "utf8"),
);
expect(
reader.pattern.test(code),
`${reader.file} no longer reads ${entry.field} (${reader.pattern})`,
).toBe(true);
}
} else {
expect(entry.iosOnly!.reason.length).toBeGreaterThan(60);
for (const file of entry.iosOnly!.sameResolver?.files ?? []) {
const code = stripComments(readFileSync(join(ROOT, file), "utf8"));
expect(
entry.iosOnly!.sameResolver!.pattern.test(code),
`${file} no longer resolves ${entry.field} with the shared resolver`,
).toBe(true);
}
}
});
}
@@ -45,6 +45,7 @@ export const GET = apiHandler(async () => {
where: { userId: user.id, deletedAt: null, value: { not: null } },
select: {
analyte: true,
biomarkerId: true,
unit: true,
value: true,
referenceLow: true,
@@ -61,6 +62,7 @@ export const GET = apiHandler(async () => {
const summary = summariseLabChanges(
rows.map((r) => ({
analyte: r.analyte,
biomarkerId: r.biomarkerId,
unit: r.unit,
value: r.value as number,
referenceLow: r.referenceLow,
@@ -39,6 +39,11 @@ vi.mock("@/lib/targets/glucose-read", async (importOriginal) => ({
vi.mock("@/lib/analytics/resting-pulse-read", () => ({
readRestingPulseProxy: vi.fn(),
}));
// The verdict's basis: the dashboard's seven-day means. Empty here, so every
// item is placed with its newest reading, else its own value.
vi.mock("@/lib/analytics/summaries-slice", () => ({
readSevenDayMeans: vi.fn(async () => ({})),
}));
vi.mock("@/lib/db", () => ({
prisma: {
@@ -596,12 +601,15 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
color: "var(--warning)",
},
source: "CDC/NCHS",
// Below a default band nobody chose, a resting pulse holds: its
// lower edge is a convention, not a goal to climb back to, so a
// steady reading is the good news and no move is a setback.
direction: {
direction: "up-good",
direction: "hold",
targetPosition: "below",
basis: "profile_default",
},
trendSentiment: "neutral",
trendSentiment: "positive",
daysInRange7d: 0,
daysLogged7d: 4,
daysInRange30d: 0,
@@ -0,0 +1,74 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { NUTRIENT_CATALOG } from "@/lib/nutrients/catalog";
import type { NutrientOverviewRow } from "../types";
const state = vi.hoisted(() => ({ rows: [] as NutrientOverviewRow[] }));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
data: { nutrients: state.rows },
isLoading: false,
isError: false,
refetch: () => undefined,
}),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ user: { gender: "MALE" } }),
}));
import { MicronutrientsCard } from "../micronutrients-card";
function render(rows: NutrientOverviewRow[], locale: "en" | "de" = "en") {
state.rows = rows;
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<MicronutrientsCard />
</I18nProvider>,
);
}
function row(nutrient: "vitamin_a" | "vitamin_d"): NutrientOverviewRow {
return {
nutrient,
unit: "ug",
latestDay: "2026-10-15",
latestAmount: 4000,
daysWithData: 3,
upperLimit: NUTRIENT_CATALOG[nutrient].upperLimit,
latestAboveUpperLimit: true,
};
}
/**
* The upper limit of vitamin A is set for preformed retinol; beta-carotene
* does not count toward it. The amount on file cannot say which form it came
* in, so the note says what the level covers, in the reader's language, and
* the citation stays a bare citation.
*/
describe("MicronutrientsCard upper-limit note", () => {
it("says the vitamin A level covers retinol only", () => {
const html = render([row("vitamin_a")]);
expect(html).toContain('data-slot="nutrients-upper-limit-note"');
expect(html).toContain("preformed vitamin A (retinol) only");
expect(html).toContain("beta-carotene");
expect(html).toContain("(EFSA UL overview 2024 (SCF 2002))");
});
it("phrases the note in the reader's language, not the citation's", () => {
const html = render([row("vitamin_a")], "de");
expect(html).toContain("Höchstmenge für Erwachsene");
expect(html).toContain("Beta-Carotin");
expect(html).not.toContain("adults");
});
it("adds no form sentence where the level covers the nutrient as such", () => {
const html = render([row("vitamin_d")]);
expect(html).toContain('data-slot="nutrients-upper-limit-note"');
expect(html).not.toContain("The level covers");
});
});
@@ -131,6 +131,9 @@ function UpperLimitNote({
source: limit.source,
},
)}
{limit.form
? ` ${t(`nutrients.micronutrients.upperLimitForm.${limit.form}`)}`
: null}
</span>
</p>
);
@@ -37,6 +37,9 @@ export interface NutrientUpperLimitDto {
value: number;
unit: string;
scope: "total" | "supplemental";
/** The chemical form the level covers; `null` for the nutrient as such. */
form?:
"retinol" | "folic_acid" | "magnesium_salts" | "alpha_tocopherol" | null;
source: string;
}
@@ -0,0 +1,91 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import type { RelatedLink } from "@/lib/links/related";
import { queryKeys } from "@/lib/query-keys";
const captured = vi.hoisted(() => ({
pickers: null as null | {
linksPath: string;
kinds: readonly string[];
onSaved: () => void;
},
}));
vi.mock("@/components/links/related-records", async () => {
const actual = await vi.importActual<
typeof import("@/components/links/related-records")
>("@/components/links/related-records");
return {
...actual,
RelationLinkPickers: (props: {
linksPath: string;
kinds: readonly string[];
onSaved: () => void;
}) => {
captured.pickers = props;
return null;
},
};
});
import { LabResultRelatedLinks } from "../lab-result-related-links";
const REPORT: RelatedLink = {
kind: "document",
id: "doc-1",
label: "Lab report March",
date: "2026-03-01T00:00:00.000Z",
href: "/documents?doc=doc-1",
via: "direct",
};
function render(qc: QueryClient) {
return renderToStaticMarkup(
<QueryClientProvider client={qc}>
<I18nProvider initialLocale="en">
<LabResultRelatedLinks labResultId="lab-1" takenAt={null} />
</I18nProvider>
</QueryClientProvider>,
);
}
/**
* A lab reading shows what it relates to where it is shown in detail (its
* edit sheet), and files the report and the visit through its own links
* route; a save refreshes every end that lists the reading back.
*/
describe("LabResultRelatedLinks", () => {
it("renders nothing before the detail read lands", () => {
expect(render(new QueryClient())).toBe("");
});
it("lists the related records and offers the report and visit pickers", () => {
const qc = new QueryClient();
qc.setQueryData(queryKeys.labResult("lab-1"), { related: [REPORT] });
const html = render(qc);
expect(html).toContain('data-slot="lab-result-related"');
expect(html).toContain("Lab report March");
expect(captured.pickers?.linksPath).toBe("/api/labs/lab-1/links");
expect(captured.pickers?.kinds).toEqual(["document", "encounter"]);
});
it("refreshes the reading and every end that lists it after a save", () => {
const qc = new QueryClient();
qc.setQueryData(queryKeys.labResult("lab-1"), { related: [] });
const spy = vi.spyOn(qc, "invalidateQueries");
render(qc);
captured.pickers?.onSaved();
const keys = spy.mock.calls.map((call) => call[0]?.queryKey);
expect(keys).toEqual(
expect.arrayContaining([
queryKeys.labResult("lab-1"),
queryKeys.documents(),
queryKeys.encounters(),
queryKeys.illness(),
]),
);
});
});
+20 -5
View File
@@ -28,7 +28,7 @@ import {
} from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import { useTableSort } from "@/hooks/use-table-sort";
import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
import { apiDelete, apiPost, apiPut } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { formatLabReading } from "@/lib/labs/format-value";
import { LabConvertedNote } from "./lab-converted-note";
@@ -40,7 +40,11 @@ import { queryKeys } from "@/lib/query-keys";
import { ReferenceRangeBadge } from "./reference-range-badge";
import { LabReferenceRangeBar } from "./lab-reference-range-bar";
import { SourceRangeNote } from "./source-range-note";
import type { LabResultDetailDto, LabResultDto } from "./types";
import {
fetchLabResultDetail,
LabResultRelatedLinks,
} from "./lab-result-related-links";
import type { LabResultDto } from "./types";
import { useLabDate, useLabNumber } from "./use-lab-format";
const NOTE_MAX_LENGTH = 2000;
@@ -242,9 +246,11 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
// Load the decrypted note (list rows withhold it).
setEditLoading(true);
try {
const detail = await apiGet<LabResultDetailDto>(
`/api/labs/${reading.id}`,
);
// Through the cache: the related section below reads the same detail.
const detail = await queryClient.fetchQuery({
queryKey: queryKeys.labResult(reading.id),
queryFn: () => fetchLabResultDetail(reading.id),
});
setEditNote(detail.note ?? "");
setEditSeed({ ...seed, note: detail.note ?? "" });
} catch {
@@ -314,6 +320,9 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
});
}
const editingTakenAt =
readings.find((reading) => reading.id === editingId)?.takenAt ?? null;
const ordered = useMemo(() => {
const dir = sortDir === "asc" ? 1 : -1;
return [...readings].sort((a, b) => {
@@ -593,6 +602,12 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
</Button>
</div>
</form>
{editingId ? (
<LabResultRelatedLinks
labResultId={editingId}
takenAt={editingTakenAt}
/>
) : null}
</ResponsiveSheet>
<SeededFormDiscardDialog
open={editDismissal.discardDialogOpen}
@@ -0,0 +1,67 @@
"use client";
/**
* A lab reading's v1.43 relation links in its edit sheet: the records the
* reading relates to (`related[]` on `GET /api/labs/{id}`, resolved on the
* server) and the pickers that file the report it was read from and the
* visit it was drawn at. The pickers write straight through
* `/api/labs/{id}/links` when picked, independent of the form's Save, the
* same way the visit sheet's medication and dose pickers do.
*/
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
RelatedRecordsBlock,
RelationLinkPickers,
} from "@/components/links/related-records";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import type { LabResultDetailDto } from "./types";
export function fetchLabResultDetail(id: string) {
return apiGet<LabResultDetailDto>(`/api/labs/${encodeURIComponent(id)}`);
}
export function LabResultRelatedLinks({
labResultId,
takenAt,
}: {
labResultId: string;
takenAt: string | null;
}) {
const qc = useQueryClient();
const detail = useQuery({
queryKey: queryKeys.labResult(labResultId),
queryFn: () => fetchLabResultDetail(labResultId),
});
// The pickers seed from this read; before it lands they would offer an
// empty selection that the first tap could not compute a change from.
if (!detail.data) return null;
const related = detail.data.related;
return (
<div className="space-y-4 border-t pt-4" data-slot="lab-result-related">
<RelatedRecordsBlock related={related} />
<RelationLinkPickers
key={labResultId}
linksPath={`/api/labs/${encodeURIComponent(labResultId)}/links`}
kinds={["document", "encounter"]}
related={related}
anchor={takenAt}
onSaved={() => {
// The other ends list the reading too: the document's and the
// visit's sheets seed their pickers from their cached reads, and a
// condition reaches the reading through the visit.
for (const queryKey of [
queryKeys.labResult(labResultId),
queryKeys.documents(),
queryKeys.encounters(),
queryKeys.illness(),
]) {
void qc.invalidateQueries({ queryKey });
}
}}
/>
</div>
);
}
+4
View File
@@ -1,3 +1,5 @@
import type { RelatedLink } from "@/lib/links/related";
import type {
ReferenceRangeOrigin,
ReferenceRangeStatus,
@@ -58,6 +60,8 @@ export interface LabResultDto {
/** The single-resource detail DTO — carries the decrypted note. */
export interface LabResultDetailDto extends Omit<LabResultDto, "hasNote"> {
note: string | null;
/** v1.43 — the records this reading relates to, resolved on the server. */
related?: RelatedLink[];
}
export interface LabResultListResponse {
@@ -0,0 +1,58 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
const captured = vi.hoisted(() => ({
onSaved: undefined as (() => void) | undefined,
}));
vi.mock("@/components/links/related-records", () => ({
RelatedRecordsList: () => null,
RelationLinkPickers: (props: { onSaved: () => void }) => {
captured.onSaved = props.onSaved;
return null;
},
}));
vi.mock("@/hooks/use-record-capabilities", () => ({
useRecordCapabilities: () => ({ inSharedRecord: false }),
}));
import { MedicationRelatedSection } from "@/components/medications/detail/medication-related-section";
/**
* A link filed from the medication is listed on the other end too, so a save
* must stale every detail that reads it back: the medication itself, the
* episode, the document and the visit.
*/
describe("MedicationRelatedSection", () => {
it("invalidates every end a saved link is read back on", () => {
const qc = new QueryClient();
const spy = vi.spyOn(qc, "invalidateQueries");
renderToStaticMarkup(
<QueryClientProvider client={qc}>
<I18nProvider initialLocale="en">
<MedicationRelatedSection
medicationId="med-1"
startsOn={null}
related={[]}
/>
</I18nProvider>
</QueryClientProvider>,
);
expect(captured.onSaved).toBeTypeOf("function");
captured.onSaved?.();
const keys = spy.mock.calls.map((call) => call[0]?.queryKey);
expect(keys).toEqual(
expect.arrayContaining([
queryKeys.medicationDetail("med-1"),
queryKeys.illness(),
queryKeys.documents(),
queryKeys.encounters(),
]),
);
});
});
@@ -46,9 +46,13 @@ export function MedicationRelatedSection({
related={related}
anchor={startsOn}
onSaved={() => {
// The other end lists the medication too.
// The other end lists the medication too: every kind the
// pickers file (episode, document, visit) reads it back on its
// own detail, so each one's prefix goes stale.
for (const queryKey of [
queryKeys.medicationDetail(medicationId),
queryKeys.illness(),
queryKeys.documents(),
queryKeys.encounters(),
]) {
void qc.invalidateQueries({ queryKey });
@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ScreeningHintsCard } from "@/components/records/screening-hints-card";
import { I18nProvider } from "@/lib/i18n/context";
import type { LifeCircumstancesDto } from "@/lib/profile/life-circumstances";
import type { ScreeningHint } from "@/lib/profile/life-derived";
import { queryKeys } from "@/lib/query-keys";
function hint(
id: ScreeningHint["id"],
status: ScreeningHint["status"],
): ScreeningHint {
return {
id,
status,
reasonKeys: [],
region: "DE",
sourceLabel: "source",
sourceUrl: "https://example.org/",
};
}
function render(tobaccoFacts: number, hints: ScreeningHint[]): string {
const dto = {
sections: [
{
key: "tobacco",
facts: Array.from({ length: tobaccoFacts }, () => ({})),
},
],
derived: { screeningHints: hints },
} as unknown as LifeCircumstancesDto;
const client = new QueryClient();
client.setQueryData(queryKeys.lifeCircumstances(), dto);
return renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">
<ScreeningHintsCard />
</I18nProvider>
</QueryClientProvider>,
);
}
describe("ScreeningHintsCard", () => {
it("stays away while nothing applies and smoking is unanswered", () => {
expect(
render(0, [
hint("lung_ldct", "unknown"),
hint("aaa_ultrasound", "not_eligible"),
]),
).toBe("");
});
// The German abdominal-aorta ultrasound applies to every man from 65,
// whether he ever smoked or not: the hint must reach him without a
// smoking answer.
it("shows a hint that applies without a smoking answer", () => {
const html = render(0, [
hint("lung_ldct", "unknown"),
hint("aaa_ultrasound", "suggested"),
]);
expect(html).toContain('data-slot="screening-hints-card"');
expect(html).toContain('data-slot="screening-hint-aaa_ultrasound"');
});
it("shows once smoking is answered", () => {
expect(
render(1, [
hint("lung_ldct", "not_eligible"),
hint("aaa_ultrasound", "not_eligible"),
]),
).toContain('data-slot="screening-hints-card"');
});
});
@@ -7,8 +7,10 @@
* source and country. A hint, never a reminder: nothing is scheduled here,
* and the card says so. Resolved on the server; rendered as delivered.
*
* The card stays away until the person has said something about smoking,
* because every hint would otherwise read "not enough information".
* The card stays away until it has something to say: the person has said
* something about smoking, or a hint already applies without it (the German
* abdominal-aorta ultrasound is offered to every man from 65, smoker or not).
* Before either, every hint would read "not enough information".
*/
import { useQuery } from "@tanstack/react-query";
import { Stethoscope } from "lucide-react";
@@ -29,7 +31,12 @@ export function ScreeningHintsCard() {
});
const data = query.data;
const tobacco = data?.sections.find((section) => section.key === "tobacco");
if (!data || !tobacco || tobacco.facts.length === 0) return null;
if (!data) return null;
const answeredTobacco = (tobacco?.facts.length ?? 0) > 0;
const anySuggested = data.derived.screeningHints.some(
(hint) => hint.status === "suggested",
);
if (!answeredTobacco && !anySuggested) return null;
return (
<SettingsCard data-slot="screening-hints-card">
@@ -100,6 +100,39 @@ describe("AiUsageCard", () => {
expect(html).not.toContain('data-slot="ai-usage-use-default"');
});
it("follows the server's estimates, not a day threshold of its own", () => {
// Two measured days, but the server already published estimates: the
// card shows the measured line because the server said it may.
const ready = render({ ...CAPPED, measuredDays: 2 });
expect(ready).toContain(
"Measured: 100,000 tokens a day over the last 2 days",
);
// Seven days, but the server withheld every estimate: no measured line.
const withheld = render({
...CAPPED,
options: CAPPED.options.map((o) => ({
...o,
estimatedTokensPerDay: null,
})),
});
expect(withheld).toContain(
"An estimate per level appears after three days",
);
});
it("stacks the estimate under the level on a narrow screen", () => {
const html = render(CAPPED);
expect(html).toMatch(
/class="[^"]*flex-col[^"]*sm:flex-row[^"]*"[^>]*>\s*<span class="min-w-0 flex-1">/,
);
expect(html).toMatch(
/data-slot="ai-usage-estimate" class="[^"]*sm:shrink-0/,
);
expect(html).not.toMatch(
/data-slot="ai-usage-estimate" class="(?:(?!sm:)[^"])*\bshrink-0/,
);
});
it("offers the way back to the default when a choice is stored", () => {
const html = render({
...CAPPED,
+35 -25
View File
@@ -70,6 +70,12 @@ export function AiUsageCard() {
const editable = provider.canConfigure && !save.isPending;
const tokens = (value: number) => new Intl.NumberFormat(locale).format(value);
const levelName = (level: AiUsageLevel) => t(LEVEL_KEYS[level].label);
// Whether the history suffices for an estimate is the server's call: it
// publishes the per-level estimates only once it does. The card reads that
// decision instead of repeating its day threshold.
const estimatesReady = usage.options.some(
(option) => option.estimatedTokensPerDay !== null,
);
const allowedLevels = usage.options
.filter((option) => option.allowed)
.map((option) => option.level);
@@ -144,31 +150,35 @@ export function AiUsageCard() {
<span className="bg-primary size-2 rounded-full" />
) : null}
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium">
{levelName(option.level)}
</span>
<span className="text-muted-foreground block text-xs">
{t(LEVEL_KEYS[option.level].hint)}
</span>
{!option.allowed ? (
<span
data-slot="ai-usage-limited"
className="text-muted-foreground block text-xs"
>
{t("settings.ai.usage.limited")}
{/* Stacked under 640 px so the estimate never squeezes the
level's name and hint into a sliver; side by side above. */}
<span className="flex min-w-0 flex-1 flex-col gap-0.5 sm:flex-row sm:items-start sm:gap-3">
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium">
{levelName(option.level)}
</span>
) : null}
</span>
<span
data-slot="ai-usage-estimate"
className="text-muted-foreground shrink-0 text-right text-xs tabular-nums"
>
{option.estimatedTokensPerDay === null
? null
: t("settings.ai.usage.estimate", {
tokens: tokens(option.estimatedTokensPerDay),
})}
<span className="text-muted-foreground block text-xs">
{t(LEVEL_KEYS[option.level].hint)}
</span>
{!option.allowed ? (
<span
data-slot="ai-usage-limited"
className="text-muted-foreground block text-xs"
>
{t("settings.ai.usage.limited")}
</span>
) : null}
</span>
<span
data-slot="ai-usage-estimate"
className="text-muted-foreground text-xs tabular-nums sm:max-w-[45%] sm:shrink-0 sm:text-right"
>
{option.estimatedTokensPerDay === null
? null
: t("settings.ai.usage.estimate", {
tokens: tokens(option.estimatedTokensPerDay),
})}
</span>
</span>
</button>
);
@@ -179,7 +189,7 @@ export function AiUsageCard() {
data-slot="ai-usage-measured"
className="text-muted-foreground text-xs"
>
{usage.measuredTokensPerDay === null || usage.measuredDays < 3
{usage.measuredTokensPerDay === null || !estimatesReady
? t("settings.ai.usage.noEstimate")
: t("settings.ai.usage.measured", {
tokens: tokens(usage.measuredTokensPerDay),
+41
View File
@@ -500,6 +500,47 @@ async function withSleepNightTotals(
return slice;
}
/**
* The seven-day mean per type, exactly as the slice computes `avg7` (the same
* canonical-source CTE, the same day weighting, the same rounding), without
* the rest of the slice. Sleep is absent: the slice summarises it per night. For a reader that needs only the dashboard's basis,
* such as the target verdicts: one aggregate query, no reading ever leaves
* the database as a row.
*/
export async function readSevenDayMeans(
userId: string,
): Promise<Record<string, number | null>> {
const priorityJson = await loadUserSourcePriority(userId);
const rankUnqualified = buildSourceRankCase(
priorityJson,
'"type"',
'"source"',
);
const rows = await prisma.$queryRawUnsafe<
Array<{ type: string; avg7: number | null }>
>(
`
WITH cm AS (${canonicalMeasurementsCte(rankUnqualified, "90 days")}
)
SELECT
m."type"::text AS type,
${AVG7_SQL}::double precision AS avg7
FROM ${dayWeightedRows("cm", SESSION_DAY_FRAME)} m
GROUP BY m."type"
`,
userId,
);
const out: Record<string, number | null> = {};
for (const row of rows) {
// Stored per stage, summarised per night by `withSleepNightTotals`: a
// mean over stage rows is not the slice's sleep figure, so it is left
// out rather than served wrong. Callers fold the nights themselves.
if (row.type === "SLEEP_DURATION") continue;
out[row.type] = round2(row.avg7);
}
return out;
}
/**
* Happy path — DAY buckets carry `count / min / max / mean`; a narrow
* `$queryRaw` carries only the windowed avgs + regression columns the
@@ -26,10 +26,12 @@ function row(
sourceLow?: number | null;
sourceHigh?: number | null;
sourceText?: string | null;
biomarkerId?: string | null;
} = {},
): LabChangeRow {
return {
analyte,
biomarkerId: opts.biomarkerId ?? null,
unit: opts.unit ?? "mg/dL",
value,
referenceLow: opts.low ?? null,
@@ -143,4 +145,48 @@ describe("summariseLabChanges", () => {
);
expect(s.present).toBe(true);
});
it("pairs two printed names of one marker by the marker", () => {
const out = summariseLabChanges(
[
row("HbA1c", 5.9, "2026-06-01", { unit: "%", biomarkerId: "bm-1" }),
row("Glykiertes Hämoglobin", 5.6, "2026-03-01", {
unit: "%",
biomarkerId: "bm-1",
}),
],
NOW,
);
expect(out.present).toBe(true);
expect(out.changes).toEqual([
expect.objectContaining({
analyte: "HbA1c",
latest: 5.9,
previous: 5.6,
delta: 0.3,
}),
]);
});
it("keeps two markers apart even when the printed name matches", () => {
const out = summariseLabChanges(
[
row("Glucose", 95, "2026-06-01", { biomarkerId: "fasting" }),
row("Glucose", 140, "2026-03-01", { biomarkerId: "postprandial" }),
],
NOW,
);
expect(out.present).toBe(false);
});
it("reports no delta across two units", () => {
const out = summariseLabChanges(
[
row("Glucose", 5.3, "2026-06-01", { unit: "mmol/L", biomarkerId: "g" }),
row("Glucose", 95, "2026-03-01", { unit: "mg/dL", biomarkerId: "g" }),
],
NOW,
);
expect(out.present).toBe(false);
});
});
+21 -5
View File
@@ -25,6 +25,12 @@ import {
/** A single numeric lab reading, with both windows it could be judged against. */
export interface LabChangeRow extends SourceReferenceRange {
analyte: string;
/**
* The marker the reading is filed under. Two reports print the same marker
* under different names ("HbA1c", "Glykiertes Hämoglobin"); the marker is
* what pairs them. A reading without one pairs by its printed name.
*/
biomarkerId?: string | null;
unit: string;
value: number;
referenceLow: number | null;
@@ -88,9 +94,18 @@ function dayKey(d: Date): string {
return d.toISOString().slice(0, 10);
}
/** Normalised analyte name for cross-panel matching (free-text tolerant). */
function analyteKey(analyte: string): string {
return analyte.trim().toLowerCase();
/**
* Cross-panel matching key: the marker when the reading is filed under one,
* else the normalised printed name (free-text tolerant).
*/
function analyteKey(row: LabChangeRow): string {
return row.biomarkerId
? `marker:${row.biomarkerId}`
: `name:${row.analyte.trim().toLowerCase()}`;
}
function sameUnit(a: LabChangeRow, b: LabChangeRow): boolean {
return a.unit.trim().toLowerCase() === b.unit.trim().toLowerCase();
}
const round2 = (n: number): number => Math.round(n * 100) / 100;
@@ -116,7 +131,7 @@ export function summariseLabChanges(
analytes = new Map();
byDay.set(day, analytes);
}
const key = analyteKey(row.analyte);
const key = analyteKey(row);
const existing = analytes.get(key);
if (!existing || row.takenAt.getTime() >= existing.takenAt.getTime()) {
analytes.set(key, row);
@@ -144,7 +159,8 @@ export function summariseLabChanges(
const changes: LabChange[] = [];
for (const [key, latestRow] of latest) {
const previousRow = previous.get(key);
if (!previousRow) continue;
// A delta across two units is no change, it is a conversion; skip it.
if (!previousRow || !sameUnit(latestRow, previousRow)) continue;
const delta = round2(latestRow.value - previousRow.value);
const direction: LabChange["direction"] =
delta > 0 ? "up" : delta < 0 ? "down" : "flat";
+18 -2
View File
@@ -62,7 +62,10 @@ import {
NUTRIENT_LABELS,
type NutrientsDailyResult,
} from "@/lib/mcp/nutrients-read";
import { NUTRIENT_CODES } from "@/lib/nutrients/catalog";
import {
NUTRIENT_CODES,
type NutrientUpperLimit,
} from "@/lib/nutrients/catalog";
import { loadIntradayPulse } from "@/lib/analytics/intraday-pulse-io";
import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format";
import { encodeOffsetCursor, decodeOffsetCursor } from "@/lib/mcp/pagination";
@@ -372,6 +375,18 @@ function plainClinicalText(
return `${head}${band}`.trim();
}
/** What the EFSA level covers where it is narrower than the nutrient. */
const UPPER_LIMIT_FORM_NOTE: Record<
NonNullable<NutrientUpperLimit["form"]>,
string
> = {
retinol:
" (preformed vitamin A, retinol, only; beta-carotene does not count toward it)",
folic_acid: " (folic acid from supplements and fortified foods)",
magnesium_salts: " (readily dissociable magnesium salts in supplements)",
alpha_tocopherol: " (alpha-tocopherol)",
};
/**
* Render a `fetch` result for a nutrient code as a plain-text citation
* sentence: the most recent non-zero day's total plus the resolved EFSA
@@ -408,8 +423,9 @@ function plainNutrientText(
if (limit && latestDay?.aboveUpperLimit) {
const share =
limit.scope === "supplemental" ? " for the supplement share" : "";
const form = limit.form ? UPPER_LIMIT_FORM_NOTE[limit.form] : "";
parts.push(
`that day is above the EFSA upper intake level of ${round1(limit.value)}${unit}${share}`,
`that day is above the EFSA upper intake level of ${round1(limit.value)}${unit}${share}${form}`,
);
}
return parts.length > 0
@@ -269,10 +269,16 @@ describe("upper intake levels (v1.43)", () => {
expect(def.upperLimit.unit, def.code).toBe(def.unit);
expect(def.upperLimit.value).toBeGreaterThan(0);
expect(def.upperLimit.source).toContain("EFSA");
// A bare citation: what the level covers is `form`, phrased per locale.
expect(def.upperLimit.source, def.code).not.toMatch(/adults|:/);
}
expect(NUTRIENT_CATALOG.vitamin_d.upperLimit?.value).toBe(100);
expect(NUTRIENT_CATALOG.magnesium.upperLimit?.scope).toBe("supplemental");
expect(NUTRIENT_CATALOG.vitamin_c.upperLimit).toBeNull();
// Vitamin A's level is set for preformed retinol; beta-carotene does not
// count toward it.
expect(NUTRIENT_CATALOG.vitamin_a.upperLimit?.form).toBe("retinol");
expect(NUTRIENT_CATALOG.vitamin_d.upperLimit?.form).toBeNull();
expect(NUTRIENT_CATALOG.dha.upperLimit).toBeNull();
});
+37 -15
View File
@@ -130,9 +130,22 @@ export interface NutrientUpperLimit {
value: number;
unit: NutrientUnit;
scope: "total" | "supplemental";
/**
* The chemical form the level is set for, where it is narrower than the
* nutrient: vitamin A's level covers preformed retinol only (beta-carotene
* does not count), folate's the folic acid, magnesium's the dissociable
* salts in supplements, vitamin E's alpha-tocopherol. `null` where the
* level covers the nutrient as such. Clients phrase it in the reader's
* language; `source` stays the bare citation.
*/
form: NutrientUpperLimitForm | null;
/** The citation (EFSA overview and the opinion it rests on), verbatim. */
source: string;
}
export type NutrientUpperLimitForm =
"retinol" | "folic_acid" | "magnesium_salts" | "alpha_tocopherol";
export interface NutrientReference {
/** EFSA value type, named in Coach prose later (Slice B). */
kind: "PRI" | "AI" | "safeLevel";
@@ -222,76 +235,85 @@ const UPPER_LIMITS: Partial<Record<NutrientCode, NutrientUpperLimit>> = {
value: 3000,
unit: "ug",
scope: "total",
source:
"EFSA UL overview 2024 (SCF 2002): preformed vitamin A (retinol), adults",
form: "retinol",
source: "EFSA UL overview 2024 (SCF 2002)",
},
vitamin_b6: {
value: 12,
unit: "mg",
scope: "total",
source: "EFSA UL overview 2024 (NDA Panel 2023): adults",
form: null,
source: "EFSA UL overview 2024 (NDA Panel 2023)",
},
folate: {
value: 1000,
unit: "ug",
scope: "supplemental",
source:
"EFSA UL overview 2024 (NDA Panel 2023): folic acid from supplements and fortified foods, adults",
form: "folic_acid",
source: "EFSA UL overview 2024 (NDA Panel 2023)",
},
vitamin_d: {
value: 100,
unit: "ug",
scope: "total",
source: "EFSA UL overview 2024 (NDA Panel 2023): adults",
form: null,
source: "EFSA UL overview 2024 (NDA Panel 2023)",
},
vitamin_e: {
value: 300,
unit: "mg",
scope: "total",
source: "EFSA UL overview 2024 (NDA Panel 2024): alpha-tocopherol, adults",
form: "alpha_tocopherol",
source: "EFSA UL overview 2024 (NDA Panel 2024)",
},
calcium: {
value: 2500,
unit: "mg",
scope: "total",
source: "EFSA UL overview 2024 (NDA Panel 2012): adults",
form: null,
source: "EFSA UL overview 2024 (NDA Panel 2012)",
},
magnesium: {
value: 250,
unit: "mg",
scope: "supplemental",
source:
"EFSA UL overview 2024 (SCF 2001): readily dissociable magnesium salts in supplements, adults",
form: "magnesium_salts",
source: "EFSA UL overview 2024 (SCF 2001)",
},
zinc: {
value: 25,
unit: "mg",
scope: "total",
source: "EFSA UL overview 2024 (SCF 2003): adults",
form: null,
source: "EFSA UL overview 2024 (SCF 2003)",
},
copper: {
value: 5,
unit: "mg",
scope: "total",
source: "EFSA UL overview 2024 (SCF 2003): adults",
form: null,
source: "EFSA UL overview 2024 (SCF 2003)",
},
selenium: {
value: 255,
unit: "ug",
scope: "total",
source: "EFSA UL overview 2024 (NDA Panel 2023): adults",
form: null,
source: "EFSA UL overview 2024 (NDA Panel 2023)",
},
molybdenum: {
value: 600,
unit: "ug",
scope: "total",
source: "EFSA UL overview 2024 (SCF 2000): adults",
form: null,
source: "EFSA UL overview 2024 (SCF 2000)",
},
iodine: {
value: 600,
unit: "ug",
scope: "total",
source: "EFSA UL overview 2024 (SCF 2002): adults",
form: null,
source: "EFSA UL overview 2024 (SCF 2002)",
},
};
+1 -1
View File
@@ -13,7 +13,7 @@ export const trendVerdict = z
direction: z
.enum(["up-good", "up-bad", "neutral", "hold"])
.describe(
"How a change counts: `up-good` a rise is progress, `up-bad` a fall is progress, `hold` the value is inside its band so staying is progress, `neutral` no judgement.",
"How a change counts: `up-good` a rise is progress, `up-bad` a fall is progress, `hold` staying is progress and a move either way is no setback, `neutral` no judgement. `hold` covers a value inside its band, a blood pressure, resting pulse or body fat below a profile-default band (a default's lower edge is no goal; below the person's own target a rise stays `up-good`), and steps or oxygen saturation above their band (more is never worse).",
),
targetPosition: z
.enum(["below", "inside", "above"])
+5
View File
@@ -34,6 +34,11 @@ export const labKeys = {
// mutation's `labResults()` invalidation evict it.
labResultsOverview: () => ["lab-results", "overview"] as const,
// One reading's detail (`GET /api/labs/{id}`): the decrypted note and the
// records it relates to. Under the `lab-results` prefix so every result
// mutation evicts it.
labResult: (id: string) => ["lab-results", "detail", id] as const,
// `/labs?analyte=<name>` (MCP deep links): the newest reading stored under
// that exact analyte name, one row, to find its marker's page. Its own key
// because the page size differs from every list read; the `lab-results`
@@ -0,0 +1,90 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({ prisma: {} }));
import { targetVerdictReference, withTargetVerdicts } from "../build-response";
import { resolveSummaryVerdicts } from "../trend-verdict";
import type { TargetItem } from "../types";
const OWN_BP = { BLOOD_PRESSURE_SYS: { min: 115, max: 125 } };
const CTX = {
profile: { dateOfBirth: null, gender: null, heightCm: null },
thresholdsJson: OWN_BP,
};
function item(
type: string,
current: number | null,
average30: number | null = null,
): TargetItem {
return {
type,
label: type,
current,
average30,
trend: "up",
unit: "",
range: null,
classification: null,
source: "",
} as TargetItem;
}
/**
* The target tile and the dashboard tile judge the same change, so they are
* placed with the same value: the seven-day mean, else the latest reading,
* from the dashboard's summary slice.
*/
describe("target verdict basis", () => {
it("places a target with the dashboard's seven-day mean, not its last reading", () => {
// Last reading 118 (inside the own band), seven-day mean 132 (above it).
const summaries = { BLOOD_PRESSURE_SYS: { avg7: 132, latest: 118 } };
const [bp] = withTargetVerdicts(
[item("BLOOD_PRESSURE", 118, 121)],
CTX,
summaries,
);
const dashboard = resolveSummaryVerdicts(summaries, CTX);
expect(bp!.direction).toEqual(dashboard.BLOOD_PRESSURE_SYS);
expect(bp!.direction?.targetPosition).toBe("above");
});
it("follows the resting heart rate for the pulse target when it exists", () => {
expect(
targetVerdictReference(
item("PULSE", 70),
{ RESTING_HEART_RATE: { avg7: 52, latest: 50 } },
undefined,
),
).toBe(52);
expect(
targetVerdictReference(
item("PULSE", 70),
{ PULSE: { avg7: null, latest: 64 } },
undefined,
),
).toBe(64);
});
it("judges BMI by the weight summary", () => {
expect(
targetVerdictReference(
item("BMI", 24),
{ WEIGHT: { avg7: 80, latest: 81 } },
undefined,
),
).toBe(80);
});
it("falls back to the item's own value without a summary", () => {
expect(
targetVerdictReference(item("SLEEP_DURATION", 7), {}, undefined),
).toBe(420);
expect(
targetVerdictReference(item("BMI", 24), {}, item("WEIGHT", null, 79)),
).toBe(79);
expect(
targetVerdictReference(item("MOOD_SCORE", null), {}, undefined),
).toBe(null);
});
});
@@ -36,6 +36,9 @@ describe("resolveTrendVerdict against the person's own target", () => {
expect(trendSentimentOf(4, v)).toBe("negative");
});
// A target the person set themselves is a goal in both directions: below
// it, climbing back up is what they asked for. This stays `up-good` even
// for the load measures, unlike the profile-default case further down.
it("calls the same rise below the target progress", () => {
const v = verdict("BLOOD_PRESSURE_SYS", 108, OWN_BP);
expect(v).toEqual({
@@ -112,6 +115,28 @@ describe("resolveTrendVerdict against a profile default", () => {
expect(verdict("BODY_FAT", 30).basis).toBe("metric_default");
});
// A default band's lower edge is a population convention, not a goal. A
// low blood pressure, resting pulse or body fat against a band nobody chose
// holds: its fall is never painted as a setback.
it("holds below a default band for the load measures", () => {
expect(verdict("BLOOD_PRESSURE_SYS", 108, null, MAN_41)).toEqual({
direction: "hold",
targetPosition: "below",
basis: "profile_default",
});
expect(verdict("BLOOD_PRESSURE_DIA", 62, null, MAN_41).direction).toBe(
"hold",
);
for (const key of ["PULSE", "RESTING_HEART_RATE"]) {
const v = verdict(key, 45, null, MAN_41);
expect(v).toMatchObject({ direction: "hold", targetPosition: "below" });
expect(trendSentimentOf(-3, v)).toBe("neutral");
}
expect(
verdict("BODY_FAT", 5, null, { ...MAN_41, gender: "MALE" }),
).toMatchObject({ direction: "hold", targetPosition: "below" });
});
it("prefers the person's own target over the profile default", () => {
expect(verdict("BLOOD_PRESSURE_SYS", 125, OWN_BP, MAN_41).basis).toBe(
"personal_target",
@@ -119,6 +144,29 @@ describe("resolveTrendVerdict against a profile default", () => {
});
});
describe("one-sided measures", () => {
it("never calls steps above the own target a setback", () => {
const own = { ACTIVITY_STEPS: { min: 8000, max: 12_000 } };
const v = verdict("ACTIVITY_STEPS", 15_000, own);
expect(v).toEqual({
direction: "hold",
targetPosition: "above",
basis: "personal_target",
});
expect(trendSentimentOf(2000, v)).not.toBe("negative");
expect(trendSentimentOf(-2000, v)).not.toBe("negative");
expect(verdict("ACTIVITY_STEPS", 5000, own).direction).toBe("up-good");
});
it("never calls oxygen saturation above the own target a setback", () => {
const own = { OXYGEN_SATURATION: { min: 92, max: 96 } };
expect(verdict("OXYGEN_SATURATION", 98, own)).toMatchObject({
direction: "hold",
targetPosition: "above",
});
});
});
describe("resolveTrendVerdict metric defaults", () => {
it.each([
["ACTIVITY_STEPS", "up-good"],
+110 -20
View File
@@ -31,6 +31,11 @@ import {
readMoodDayRollups,
} from "@/lib/rollups/mood-rollups";
import { readRestingPulseProxy } from "@/lib/analytics/resting-pulse-read";
import { readSevenDayMeans } from "@/lib/analytics/summaries-slice";
import {
summarizeSleepNights,
type SleepStageRow,
} from "@/lib/analytics/sleep-night";
import { buildGlucoseTargets } from "./glucose-builder";
import { readGlucoseTargetSummaries } from "./glucose-read";
import { buildMedicationTarget } from "./medication-builder";
@@ -112,6 +117,7 @@ export async function buildTargetsResponse(user: AuthedUser) {
latestMoodEntry,
glucoseSummaries,
restingPulseProxy,
sevenDayMeans,
] = await Promise.all([
limit(() =>
prisma.user.findUnique({
@@ -247,6 +253,10 @@ export async function buildTargetsResponse(user: AuthedUser) {
timeZone: timezone,
}),
),
// The verdict is placed with the value the dashboard tile places it
// with: the seven-day mean, computed by the summary slice's own query,
// so a target and its tile never disagree on which way a change counts.
limit(() => readSevenDayMeans(userId)),
]);
// Rollup coverage fallback stays an explicit repository read here rather
@@ -369,14 +379,26 @@ export async function buildTargetsResponse(user: AuthedUser) {
// v1.43 (ios #116 item 3) — every item carries which way its change
// counts, judged with the same rule the dashboard tiles use.
const verdictTargets = withTargetVerdicts(targets, {
profile: {
heightCm,
dateOfBirth: dbUser?.dateOfBirth ?? null,
gender: dbUser?.gender ?? null,
const verdictTargets = withTargetVerdicts(
targets,
{
profile: {
heightCm,
dateOfBirth: dbUser?.dateOfBirth ?? null,
gender: dbUser?.gender ?? null,
},
thresholdsJson: dbUser?.thresholdsJson ?? null,
},
thresholdsJson: dbUser?.thresholdsJson ?? null,
});
verdictSummaries(sevenDayMeans, latestByType, {
// Sleep the way the dashboard summarises it: per night, from the
// same stage rows and source ladder.
SLEEP_DURATION: summarizeSleepNights(
sleepStageRows as SleepStageRow[],
timezone,
dbUser?.sourcePriorityJson ?? null,
).summary,
}),
);
targets.splice(0, targets.length, ...verdictTargets);
const pageSummary = buildTargetPageSummary(targets);
@@ -412,28 +434,96 @@ export async function buildTargetsResponse(user: AuthedUser) {
}
/**
* Lay the verdict over each target item. The reference is the item's latest
* value, else its thirty-day average, in the unit the item is served in:
* sleep is served in hours and judged in minutes, and BMI is judged by the
* weight it is derived from.
* The dashboard summary each target item's verdict is placed with: the key
* its dashboard tile reads its direction under. Blood pressure is judged by
* its systolic side, BMI by the weight it is derived from, and the resting
* pulse by the resting heart rate where the account records one (the tile
* shows that series then).
*/
function summaryKeysFor(type: string): string[] {
switch (type) {
case "BLOOD_PRESSURE":
return ["BLOOD_PRESSURE_SYS"];
case "BMI":
return ["WEIGHT"];
case "PULSE":
return ["RESTING_HEART_RATE", "PULSE"];
default:
return [type];
}
}
type VerdictSummaries = Readonly<
Record<string, { avg7: number | null; latest: number | null } | undefined>
>;
/**
* The dashboard's `{ avg7, latest }` per type: the slice's seven-day mean,
* and the newest reading where there is none in the last seven days.
*/
function verdictSummaries(
sevenDayMeans: Readonly<Record<string, number | null>>,
latestByType: TargetValueByType,
folded: Readonly<
Record<string, { avg7: number | null; latest: number | null }>
>,
): VerdictSummaries {
const out: Record<string, { avg7: number | null; latest: number | null }> =
{};
const types = new Set([
...Object.keys(sevenDayMeans),
...Object.keys(latestByType),
]);
for (const type of types) {
out[type] = {
avg7: sevenDayMeans[type] ?? null,
latest: latestByType[type as keyof TargetValueByType] ?? null,
};
}
return { ...out, ...folded };
}
/**
* The value a target's verdict is placed with, in the stored unit: the same
* basis as the dashboard tile (`resolveSummaryVerdicts`), the seven-day mean
* else the latest reading. Only when the account has no summary for the type
* does the item's own value stand in (latest, else thirty-day average; sleep
* is served in hours and stored in minutes).
*/
export function targetVerdictReference(
item: Pick<TargetItem, "type" | "current" | "average30">,
summaries: VerdictSummaries,
weightItem: Pick<TargetItem, "current" | "average30"> | undefined,
): number | null {
for (const key of summaryKeysFor(item.type)) {
const summary = summaries[key];
const value = summary ? (summary.avg7 ?? summary.latest) : null;
if (value !== null && value !== undefined) return value;
}
const own =
item.type === "BMI"
? weightItem
? (weightItem.current ?? weightItem.average30)
: null
: (item.current ?? item.average30);
if (own === null) return null;
return item.type === "SLEEP_DURATION" ? own * 60 : own;
}
/**
* Lay the verdict over each target item, placed with the dashboard's basis
* (`targetVerdictReference`).
*/
export function withTargetVerdicts(
targets: readonly TargetItem[],
ctx: Omit<TrendVerdictContext, "referenceValue">,
summaries: VerdictSummaries,
): TargetItem[] {
const weight = targets.find((item) => item.type === "WEIGHT");
const weightReference = weight ? (weight.current ?? weight.average30) : null;
return targets.map((item) => {
const own = item.current ?? item.average30;
const reference =
item.type === "BMI"
? weightReference
: own !== null && item.type === "SLEEP_DURATION"
? own * 60
: own;
const direction = resolveTrendVerdict(item.type, {
...ctx,
referenceValue: reference,
referenceValue: targetVerdictReference(item, summaries, weight),
});
return {
...item,
+45 -1
View File
@@ -17,6 +17,17 @@
* below the band → a rise is progress (`up-good`)
* above the band → a fall is progress (`up-bad`)
* inside the band → holding is progress (`hold`)
* - two exceptions to "below / above":
* a low reading of a load measure (blood pressure, resting pulse, body
* fat) against a band nobody chose is `hold`, not `up-good`. A
* default band's lower edge is a population convention, not a goal:
* a well-trained resting pulse of 48 or a systolic of 108 is not
* something to climb out of, and painting its fall orange would tell
* the person to raise it. Only a target the person set themselves
* turns "below" into "a rise is progress".
* a one-sided measure (steps, oxygen saturation) above its band is
* `hold`, never `up-bad`: more steps than the target, or a saturation
* above it, is never a setback.
* - no band, or no value to place: the metric's own default direction.
*
* Weight keeps its own rule (`weight-trend.ts`): only a target the person set
@@ -200,6 +211,39 @@ export function placeAgainstBand(
return "inside";
}
/**
* Load measures whose default band's lower edge is no goal (see the module
* comment): below a profile-default band they hold rather than climb.
*/
const LOW_IS_NOT_A_SETBACK: ReadonlySet<ThresholdMetric> = new Set([
"BLOOD_PRESSURE_SYS",
"BLOOD_PRESSURE_DIA",
"PULSE",
"BODY_FAT",
]);
/** Measures where more is never worse: above the band they hold. */
const ONE_SIDED_UPWARD: ReadonlySet<ThresholdMetric> = new Set([
"ACTIVITY_STEPS",
"OXYGEN_SATURATION",
]);
function directionFor(
metric: ThresholdMetric,
position: TrendTargetPosition,
basis: Exclude<TrendVerdictBasis, "metric_default">,
): TrendDirectionSentiment {
if (
position === "below" &&
basis === "profile_default" &&
LOW_IS_NOT_A_SETBACK.has(metric)
) {
return "hold";
}
if (position === "above" && ONE_SIDED_UPWARD.has(metric)) return "hold";
return DIRECTION_BY_POSITION[position];
}
const DIRECTION_BY_POSITION: Record<
TrendTargetPosition,
TrendDirectionSentiment
@@ -246,7 +290,7 @@ export function resolveTrendVerdict(
toUnit ? toUnit(reference) : reference,
);
return {
direction: DIRECTION_BY_POSITION[position],
direction: directionFor(metric, position, personal.basis),
targetPosition: position,
basis: personal.basis,
};
+11 -1
View File
@@ -112,7 +112,17 @@ export const nutrientUpperLimitSchema = z
value: z.number(),
unit: z.string(),
scope: z.enum(["total", "supplemental"]),
source: z.string(),
form: z
.enum(["retinol", "folic_acid", "magnesium_salts", "alpha_tocopherol"])
.nullable()
.describe(
"v1.43 — the chemical form the level is set for, where narrower than the nutrient: `retinol` (vitamin A: preformed retinol only, beta-carotene does not count), `folic_acid`, `magnesium_salts` (readily dissociable salts in supplements), `alpha_tocopherol`. `null` where the level covers the nutrient as such. Phrase it in the reader's language; skip an unknown value.",
),
source: z
.string()
.describe(
"The bare citation (EFSA overview and the opinion it rests on), not prose. Every level here is the adult one.",
),
})
.meta({
id: "NutrientUpperLimit",
@@ -13,6 +13,10 @@ import { beforeAll, describe, expect, it, vi } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
import { deriveRestingProxyFromPulse } from "@/lib/analytics/resting-pulse";
import {
computeSummariesSlice,
readSevenDayMeans,
} from "@/lib/analytics/summaries-slice";
import { buildTargetsResponse } from "@/lib/targets/build-response";
import { buildGlucoseTargets } from "@/lib/targets/glucose-builder";
import {
@@ -294,3 +298,22 @@ describe("targets fold equals the raw-reading fold", () => {
});
}
});
// The target verdict is placed with the dashboard tile's basis. Proven on
// the database, not assumed: the seven-day means the targets read equal the
// `avg7` the dashboard slice serves, type for type, on both accounts.
describe("target verdict basis equals the dashboard's", () => {
for (const userId of [SPARSE, DENSE]) {
it(`reads the slice's seven-day mean (${userId})`, async () => {
const [means, slice] = await Promise.all([
readSevenDayMeans(userId),
computeSummariesSlice(userId),
]);
const withData = Object.keys(means);
expect(withData.length).toBeGreaterThan(0);
for (const type of withData) {
expect(means[type], type).toBe(slice.summaries[type]?.avg7 ?? null);
}
});
}
});