mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge branch 'v143/fixui' into release/v1.43.0
This commit is contained in:
commit
d3f6d2d7c7
38 files changed
+1260
-127
No files matched your search
+20
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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(),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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)",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user