diff --git a/messages/de.json b/messages/de.json index a9c1f83c5..421fcb35c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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": { diff --git a/messages/en.json b/messages/en.json index ca6175a86..b80e0a938 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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": { diff --git a/messages/es.json b/messages/es.json index 8b39b0e3f..bb81a534a 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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": { diff --git a/messages/fr.json b/messages/fr.json index 92e11b7cd..96777e4a8 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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": { diff --git a/messages/it.json b/messages/it.json index c25858eae..4acd04e2f 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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": { diff --git a/messages/ko.json b/messages/ko.json index 56230ec63..1bfd80398 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -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": { diff --git a/messages/pl.json b/messages/pl.json index 0a5590b85..8f35637a7 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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": { diff --git a/src/components/insights/nutrients/__tests__/micronutrients-upper-limit.test.tsx b/src/components/insights/nutrients/__tests__/micronutrients-upper-limit.test.tsx new file mode 100644 index 000000000..53e25e3cb --- /dev/null +++ b/src/components/insights/nutrients/__tests__/micronutrients-upper-limit.test.tsx @@ -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( + + + , + ); +} + +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"); + }); +}); diff --git a/src/components/insights/nutrients/micronutrients-card.tsx b/src/components/insights/nutrients/micronutrients-card.tsx index 70bbaf973..9ddd76809 100644 --- a/src/components/insights/nutrients/micronutrients-card.tsx +++ b/src/components/insights/nutrients/micronutrients-card.tsx @@ -131,6 +131,9 @@ function UpperLimitNote({ source: limit.source, }, )} + {limit.form + ? ` ${t(`nutrients.micronutrients.upperLimitForm.${limit.form}`)}` + : null}

); diff --git a/src/components/insights/nutrients/types.ts b/src/components/insights/nutrients/types.ts index ca7848aa5..e59289586 100644 --- a/src/components/insights/nutrients/types.ts +++ b/src/components/insights/nutrients/types.ts @@ -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; } diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts index fa3d851d8..d6e131788 100644 --- a/src/lib/mcp/tools.ts +++ b/src/lib/mcp/tools.ts @@ -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"; @@ -371,6 +374,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, + 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 @@ -407,8 +422,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 diff --git a/src/lib/nutrients/__tests__/catalog.test.ts b/src/lib/nutrients/__tests__/catalog.test.ts index 33709a6d0..37b6911b7 100644 --- a/src/lib/nutrients/__tests__/catalog.test.ts +++ b/src/lib/nutrients/__tests__/catalog.test.ts @@ -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(); }); diff --git a/src/lib/nutrients/catalog.ts b/src/lib/nutrients/catalog.ts index 0efe0f096..9aab97e8e 100644 --- a/src/lib/nutrients/catalog.ts +++ b/src/lib/nutrients/catalog.ts @@ -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> = { 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)", }, }; diff --git a/src/lib/validations/nutrients.ts b/src/lib/validations/nutrients.ts index 2fad5c706..1b3153f55 100644 --- a/src/lib/validations/nutrients.ts +++ b/src/lib/validations/nutrients.ts @@ -111,7 +111,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",