From 5445fc67eaf189291d2b3aab72cc04a983f5cb34 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Tue, 14 Jul 2026 13:10:47 +0200
Subject: [PATCH] =?UTF-8?q?chore(release):=20v1.28.34=20=E2=80=94=20nutrie?=
=?UTF-8?q?nt=20intake=20lands=20as=20quiet=20context=20(server=20side)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Supplement-style intake written to Apple Health by nutrition apps —
vitamins, minerals, water, caffeine — can now sync as daily totals into a
dedicated NutrientIntakeDay store (migration 0241): composite (user, day,
nutrient) identity, re-post replaces, no soft delete, no rollup coupling.
A code-side catalog carries the 26 nutrient codes with canonical units,
plausibility caps, and EFSA dietary reference values (direction-aware;
chromium cites NIH ODS where EFSA declined a DRV).
Deliberately not a nutrition feature: no diary, no calories, no dashboard
surface. POST /api/nutrients/batch is gated behind the new opt-in
nutrients module (default off — with the module off the server refuses
ingest entirely), rate-limited, idempotency-wrapped, with per-entry
unit-mismatch and out-of-range skips. GET /api/nutrients serves a
catalog-ordered summary; the only visible surface is a read-only card
under Settings → Sources. Coach/correlation access is a later slice; the
iOS reading side is coordinated in the companion-app tracker.
---
CHANGELOG.md | 15 +
docs/api/openapi.yaml | 334 ++++++++++++++++-
messages/de.json | 45 ++-
messages/en.json | 45 ++-
messages/es.json | 45 ++-
messages/fr.json | 45 ++-
messages/it.json | 45 ++-
messages/pl.json | 45 ++-
package.json | 2 +-
.../0241_nutrient_intake_days/migration.sql | 37 ++
prisma/schema.prisma | 44 +++
public/sw.js | 2 +-
.../module-route-gate-inventory.test.ts | 7 +
src/app/api/nutrients/__tests__/route.test.ts | 146 ++++++++
.../nutrients/batch/__tests__/route.test.ts | 354 ++++++++++++++++++
src/app/api/nutrients/batch/route.ts | 248 ++++++++++++
src/app/api/nutrients/route.ts | 97 +++++
.../__tests__/nutrient-intake-card.test.tsx | 108 ++++++
src/components/settings/modules-section.tsx | 3 +
.../settings/nutrient-intake-card.tsx | 130 +++++++
src/components/settings/sources-section.tsx | 5 +
src/lib/dashboard/__tests__/snapshot.test.ts | 1 +
src/lib/measurements/apple-health-mapping.ts | 10 +-
src/lib/modules/__tests__/gate.test.ts | 8 +-
src/lib/modules/registry.ts | 20 +
src/lib/nutrients/__tests__/catalog.test.ts | 153 ++++++++
src/lib/nutrients/catalog.ts | 307 +++++++++++++++
src/lib/openapi/routes/index.ts | 3 +
src/lib/openapi/routes/nutrients.ts | 93 +++++
src/lib/query-keys/index.ts | 2 +
src/lib/query-keys/nutrients.ts | 9 +
src/lib/validations/nutrients.ts | 108 ++++++
.../doctor-report-sections.test.ts | 1 +
tests/integration/nutrients-batch.test.ts | 261 +++++++++++++
34 files changed, 2766 insertions(+), 12 deletions(-)
create mode 100644 prisma/migrations/0241_nutrient_intake_days/migration.sql
create mode 100644 src/app/api/nutrients/__tests__/route.test.ts
create mode 100644 src/app/api/nutrients/batch/__tests__/route.test.ts
create mode 100644 src/app/api/nutrients/batch/route.ts
create mode 100644 src/app/api/nutrients/route.ts
create mode 100644 src/components/settings/__tests__/nutrient-intake-card.test.tsx
create mode 100644 src/components/settings/nutrient-intake-card.tsx
create mode 100644 src/lib/nutrients/__tests__/catalog.test.ts
create mode 100644 src/lib/nutrients/catalog.ts
create mode 100644 src/lib/openapi/routes/nutrients.ts
create mode 100644 src/lib/query-keys/nutrients.ts
create mode 100644 src/lib/validations/nutrients.ts
create mode 100644 tests/integration/nutrients-batch.test.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index aa0aa539b..57594af12 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,21 @@
## [Unreleased]
+## [1.28.34] — 2026-07-14 — Nutrient intake lands as quiet context (server side)
+
+Supplement-style intake written to Apple Health by nutrition apps — vitamins,
+minerals, water, caffeine — can now sync to HealthLog as daily totals. This is
+deliberately not a nutrition feature: no food diary, no calorie tracking, no
+dashboard tile. The data lands in a dedicated store (26 nutrients with EFSA
+reference values), is visible on a read-only card under Settings → Sources,
+and will feed the Coach as context in a later release.
+
+The module is opt-in and off by default; with it off, the server refuses the
+data entirely. Re-posting a day replaces it, units are guarded against
+µg/mg mix-ups, and implausible values are skipped per entry. This release is
+inert until the iOS companion app ships its reading side (coordination ticket
+filed); the wire contract is in the OpenAPI spec.
+
## [1.28.33] — 2026-07-14 — Re-importing a cumulative Apple Health export works again
Re-uploading a fresh export.zip over existing data could fail with a duplicate-key
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index f81246937..2f1e203f9 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
- version: 1.28.33
+ version: 1.28.34
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -10355,6 +10355,128 @@ paths:
"401": *a1
"422": *a3
"429": *a4
+ /api/nutrients/batch:
+ post:
+ tags:
+ - Nutrients
+ summary: Ingest micronutrient day totals (v1.28)
+ description: >-
+ Bulk upsert of supplement-style daily totals synced from HealthKit — vitamins, minerals, water, caffeine. Day
+ totals only: compute HKStatisticsCollectionQuery cumulative sums day-anchored in the user's current IANA
+ timezone; `day` is yyyy-MM-dd in that timezone (the same rule as the `stats:` day keys). Never post raw samples.
+ Upsert key is (day, nutrient): a re-post replaces the stored total and reports `updated`. Post the current +
+ previous local day per sync; backfill 30 days on first enable. Max 500 entries; Idempotency-Key supported; rate
+ limit 60/min. Per-entry skips (`unit_mismatch` | `value_out_of_range` | `day_invalid`) are terminal — log and
+ drop, do not retry the entry. Behind the opt-in `nutrients` module (403 `module.disabled` when off). Query
+ HealthKit with exactly the listed unit per code (`ug` = microgram; do not convert to IU):
+
+
+ | code | HealthKit identifier | unit |
+
+ | --- | --- | --- |
+
+ | vitamin_a | HKQuantityTypeIdentifierDietaryVitaminA | ug |
+
+ | thiamin | HKQuantityTypeIdentifierDietaryThiamin | mg |
+
+ | riboflavin | HKQuantityTypeIdentifierDietaryRiboflavin | mg |
+
+ | niacin | HKQuantityTypeIdentifierDietaryNiacin | mg |
+
+ | pantothenic_acid | HKQuantityTypeIdentifierDietaryPantothenicAcid | mg |
+
+ | vitamin_b6 | HKQuantityTypeIdentifierDietaryVitaminB6 | mg |
+
+ | biotin | HKQuantityTypeIdentifierDietaryBiotin | ug |
+
+ | folate | HKQuantityTypeIdentifierDietaryFolate | ug |
+
+ | vitamin_b12 | HKQuantityTypeIdentifierDietaryVitaminB12 | ug |
+
+ | vitamin_c | HKQuantityTypeIdentifierDietaryVitaminC | mg |
+
+ | vitamin_d | HKQuantityTypeIdentifierDietaryVitaminD | ug |
+
+ | vitamin_e | HKQuantityTypeIdentifierDietaryVitaminE | mg |
+
+ | vitamin_k | HKQuantityTypeIdentifierDietaryVitaminK | ug |
+
+ | calcium | HKQuantityTypeIdentifierDietaryCalcium | mg |
+
+ | iron | HKQuantityTypeIdentifierDietaryIron | mg |
+
+ | magnesium | HKQuantityTypeIdentifierDietaryMagnesium | mg |
+
+ | phosphorus | HKQuantityTypeIdentifierDietaryPhosphorus | mg |
+
+ | zinc | HKQuantityTypeIdentifierDietaryZinc | mg |
+
+ | copper | HKQuantityTypeIdentifierDietaryCopper | mg |
+
+ | manganese | HKQuantityTypeIdentifierDietaryManganese | mg |
+
+ | selenium | HKQuantityTypeIdentifierDietarySelenium | ug |
+
+ | chromium | HKQuantityTypeIdentifierDietaryChromium | ug |
+
+ | molybdenum | HKQuantityTypeIdentifierDietaryMolybdenum | ug |
+
+ | iodine | HKQuantityTypeIdentifierDietaryIodine | ug |
+
+ | water | HKQuantityTypeIdentifierDietaryWater | ml |
+
+ | caffeine | HKQuantityTypeIdentifierDietaryCaffeine | mg |
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/NutrientBatchRequest"
+ responses:
+ "200":
+ description: Batch processed; per-entry statuses inserted | updated | skipped.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/NutrientBatchEnvelope"
+ "401": *a1
+ "403": &a17
+ description: The opt-in `nutrients` module is off for this account (errorCode `module.disabled`). Stop syncing and do
+ not retry; offer the enable-in-settings hint. Only run the HealthKit read-authorization flow while the
+ module is on.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorEnvelope"
+ "422": *a3
+ "429": *a4
+ /api/nutrients:
+ get:
+ tags:
+ - Nutrients
+ summary: Synced-nutrient window summary (v1.28)
+ description: "Per-nutrient summary of the last `days` days (default 14): latest synced day + total and the
+ days-with-data count, in catalog order. Read-only; feeds the settings card. Behind the opt-in `nutrients`
+ module."
+ parameters:
+ - in: query
+ name: days
+ schema:
+ default: 14
+ type: integer
+ minimum: 1
+ maximum: 365
+ responses:
+ "200":
+ description: The window summary.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/NutrientOverviewEnvelope"
+ "401": *a1
+ "403": *a17
+ "422": *a3
+ "429": *a4
components:
schemas:
EncryptedExportRequest:
@@ -11501,6 +11623,8 @@ components:
type: boolean
mentalHealth:
type: boolean
+ nutrients:
+ type: boolean
additionalProperties: false
description: Partial module enable/disable update. Each key is an optional boolean; an omitted key is left untouched,
`false` disables the module, `true` (re-)enables it. Only the toggleable "secondary domains" are accepted — a
@@ -12143,6 +12267,76 @@ components:
- updatedAt
description: Resumable module-tour progress point. `lastStopId` seeds the resume index; `status` is the running/terminal
state.
+ NutrientBatchRequest:
+ type: object
+ properties:
+ entries:
+ minItems: 1
+ maxItems: 500
+ type: array
+ items:
+ $ref: "#/components/schemas/NutrientIntakeEntry"
+ required:
+ - entries
+ description: Bulk day-total upsert, max 500 entries. Upsert key is (day, nutrient); a re-post replaces the stored total
+ (last-writer-wins day-total contract).
+ NutrientIntakeEntry:
+ type: object
+ properties:
+ day:
+ type: string
+ pattern: ^\d{4}-\d{2}-\d{2}$
+ nutrient:
+ $ref: "#/components/schemas/NutrientCode"
+ unit:
+ type: string
+ minLength: 1
+ maxLength: 10
+ amount:
+ type: number
+ minimum: 0
+ externalSourceVersion:
+ type: string
+ minLength: 1
+ maxLength: 120
+ required:
+ - day
+ - nutrient
+ - unit
+ - amount
+ description: One nutrient day total. day = YYYY-MM-DD in the user's IANA timezone; unit must equal the catalog's
+ canonical unit for the code (mg | ug | ml) or the entry is skipped unit_mismatch.
+ NutrientCode:
+ type: string
+ enum:
+ - vitamin_a
+ - thiamin
+ - riboflavin
+ - niacin
+ - pantothenic_acid
+ - vitamin_b6
+ - biotin
+ - folate
+ - vitamin_b12
+ - vitamin_c
+ - vitamin_d
+ - vitamin_e
+ - vitamin_k
+ - calcium
+ - iron
+ - magnesium
+ - phosphorus
+ - zinc
+ - copper
+ - manganese
+ - selenium
+ - chromium
+ - molybdenum
+ - iodine
+ - water
+ - caffeine
+ description: Closed nutrient catalog code — 24 HealthKit Dietary vitamin/mineral types plus water and caffeine. Energy,
+ macros and sodium/potassium are out of scope.
MedicationTreatmentClass:
type: string
enum:
@@ -19022,6 +19216,9 @@ components:
mentalHealth:
type: boolean
description: Whether the "mentalHealth" module is enabled for this account.
+ nutrients:
+ type: boolean
+ description: Whether the "nutrients" module is enabled for this account.
required:
- cycle
- mood
@@ -19040,6 +19237,7 @@ components:
- mcp
- inboundDocuments
- mentalHealth
+ - nutrients
additionalProperties: false
description: Fully-resolved per-user module enable/disable map. Every toggleable module key is present. `false` means
the surface should disappear end-to-end (nav, dashboard, insights, …). `cycle` mirrors `cycleTrackingEnabled`;
@@ -28359,6 +28557,140 @@ components:
- progress
additionalProperties: false
description: The persisted completion flag and resume point after the write.
+ NutrientBatchEnvelope:
+ type: object
+ properties:
+ data:
+ $ref: "#/components/schemas/NutrientBatchResponse"
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ NutrientBatchResponse:
+ type: object
+ properties:
+ processed:
+ type: integer
+ minimum: 0
+ maximum: 9007199254740991
+ inserted:
+ type: integer
+ minimum: 0
+ maximum: 9007199254740991
+ updated:
+ type: integer
+ minimum: 0
+ maximum: 9007199254740991
+ skipped:
+ type: array
+ items:
+ type: object
+ properties:
+ index:
+ type: integer
+ minimum: 0
+ maximum: 9007199254740991
+ reason:
+ type: string
+ required:
+ - index
+ - reason
+ additionalProperties: false
+ entries:
+ type: array
+ items:
+ $ref: "#/components/schemas/NutrientEntryResult"
+ required:
+ - processed
+ - inserted
+ - updated
+ - skipped
+ - entries
+ additionalProperties: false
+ description: Always 200 when the envelope parses; per-entry failures surface as skipped entries, never a batch failure.
+ NutrientEntryResult:
+ type: object
+ properties:
+ index:
+ type: integer
+ minimum: 0
+ maximum: 9007199254740991
+ status:
+ type: string
+ enum:
+ - inserted
+ - updated
+ - skipped
+ reason:
+ type: string
+ required:
+ - index
+ - status
+ additionalProperties: false
+ description: "Per-entry ingest outcome. skipped reasons: unit_mismatch | value_out_of_range | day_invalid |
+ upsert_failed. Log and drop a skipped entry — do not retry it."
+ NutrientOverviewEnvelope:
+ type: object
+ properties:
+ data:
+ $ref: "#/components/schemas/NutrientIntakeOverview"
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ NutrientIntakeOverview:
+ type: object
+ properties:
+ windowDays:
+ type: integer
+ minimum: 1
+ maximum: 9007199254740991
+ nutrients:
+ type: array
+ items:
+ type: object
+ properties:
+ nutrient:
+ $ref: "#/components/schemas/NutrientCode"
+ unit:
+ type: string
+ latestDay:
+ type: string
+ latestAmount:
+ type: number
+ daysWithData:
+ type: integer
+ minimum: 1
+ maximum: 9007199254740991
+ required:
+ - nutrient
+ - unit
+ - latestDay
+ - latestAmount
+ - daysWithData
+ additionalProperties: false
+ required:
+ - windowDays
+ - nutrients
+ additionalProperties: false
+ description: "Per-nutrient window summary in catalog order: latest synced day + total, and the count of days carrying
+ data inside the window. Nutrients without data in the window are omitted."
MedicationScheduleOutput:
type: object
properties:
diff --git a/messages/de.json b/messages/de.json
index cca68b5f1..0d465fd6a 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -4238,7 +4238,16 @@
"other": "Sonstige",
"unknown": "Unbekannt"
},
- "subtitle": "Lege fest, welche Quelle gewinnt, wenn zwei dieselbe Messgröße liefern."
+ "subtitle": "Lege fest, welche Quelle gewinnt, wenn zwei dieselbe Messgröße liefern.",
+ "nutrients": {
+ "title": "Synchronisierte Nährstoffzufuhr",
+ "description": "Tagessummen aus Apple Health der letzten {days} Tage. Nur lesend — die Quell-App besitzt die Werte.",
+ "empty": "Noch keine Nährstoffdaten synchronisiert.",
+ "latest": "{amount} {unit} am {date}",
+ "daysWithData": "{count} von {days} Tagen",
+ "moduleHint": "Die Synchronisierung steuert das Modul „Nährstoffzufuhr“.",
+ "moduleHintLink": "Module verwalten"
+ }
},
"ai": {
"title": "KI-Anbieter",
@@ -7570,6 +7579,10 @@
"mentalHealth": {
"label": "Seelisches Wohlbefinden",
"description": "Freiwillige PHQ-9- und GAD-7-Selbsttests ergänzend zum Stimmungstagebuch. Standardmäßig aus; Antworten sind verschlüsselt und bleiben aus dem KI-Coach heraus."
+ },
+ "nutrients": {
+ "label": "Nährstoffzufuhr",
+ "description": "Speichert tägliche Mikronährstoff-Summen (Vitamine, Mineralstoffe, Wasser, Koffein) aus Apple Health. Standardmäßig aus."
}
},
"illness": {
@@ -8452,5 +8465,35 @@
"valuesDescription": "Alle erfassten Werte dieser Metrik.",
"backToMetric": "Zurück zu {metric}"
}
+ },
+ "nutrients": {
+ "names": {
+ "vitamin_a": "Vitamin A",
+ "thiamin": "Thiamin (B1)",
+ "riboflavin": "Riboflavin (B2)",
+ "niacin": "Niacin (B3)",
+ "pantothenic_acid": "Pantothensäure (B5)",
+ "vitamin_b6": "Vitamin B6",
+ "biotin": "Biotin (B7)",
+ "folate": "Folat (B9)",
+ "vitamin_b12": "Vitamin B12",
+ "vitamin_c": "Vitamin C",
+ "vitamin_d": "Vitamin D",
+ "vitamin_e": "Vitamin E",
+ "vitamin_k": "Vitamin K",
+ "calcium": "Calcium",
+ "iron": "Eisen",
+ "magnesium": "Magnesium",
+ "phosphorus": "Phosphor",
+ "zinc": "Zink",
+ "copper": "Kupfer",
+ "manganese": "Mangan",
+ "selenium": "Selen",
+ "chromium": "Chrom",
+ "molybdenum": "Molybdän",
+ "iodine": "Jod",
+ "water": "Wasser",
+ "caffeine": "Koffein"
+ }
}
}
diff --git a/messages/en.json b/messages/en.json
index 4a0fc6c37..efdd27279 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -4238,7 +4238,16 @@
"other": "Other",
"unknown": "Unknown"
},
- "subtitle": "Decide which source wins when two report the same metric."
+ "subtitle": "Decide which source wins when two report the same metric.",
+ "nutrients": {
+ "title": "Synced nutrient intake",
+ "description": "Daily totals synced from Apple Health over the last {days} days. Read-only — the source app owns the numbers.",
+ "empty": "No nutrient data has been synced yet.",
+ "latest": "{amount} {unit} on {date}",
+ "daysWithData": "{count} of {days} days",
+ "moduleHint": "Syncing is controlled by the Nutrient intake module.",
+ "moduleHintLink": "Manage modules"
+ }
},
"ai": {
"title": "AI provider",
@@ -7570,6 +7579,10 @@
"mentalHealth": {
"label": "Mental wellbeing",
"description": "Opt-in PHQ-9 and GAD-7 screeners beside mood tracking. Off by default; answers are encrypted and kept out of the AI Coach."
+ },
+ "nutrients": {
+ "label": "Nutrient intake",
+ "description": "Store daily micronutrient totals (vitamins, minerals, water, caffeine) synced from Apple Health. Off by default."
}
},
"illness": {
@@ -8452,5 +8465,35 @@
"valuesDescription": "Every logged value for this metric.",
"backToMetric": "Back to {metric}"
}
+ },
+ "nutrients": {
+ "names": {
+ "vitamin_a": "Vitamin A",
+ "thiamin": "Thiamin (B1)",
+ "riboflavin": "Riboflavin (B2)",
+ "niacin": "Niacin (B3)",
+ "pantothenic_acid": "Pantothenic acid (B5)",
+ "vitamin_b6": "Vitamin B6",
+ "biotin": "Biotin (B7)",
+ "folate": "Folate (B9)",
+ "vitamin_b12": "Vitamin B12",
+ "vitamin_c": "Vitamin C",
+ "vitamin_d": "Vitamin D",
+ "vitamin_e": "Vitamin E",
+ "vitamin_k": "Vitamin K",
+ "calcium": "Calcium",
+ "iron": "Iron",
+ "magnesium": "Magnesium",
+ "phosphorus": "Phosphorus",
+ "zinc": "Zinc",
+ "copper": "Copper",
+ "manganese": "Manganese",
+ "selenium": "Selenium",
+ "chromium": "Chromium",
+ "molybdenum": "Molybdenum",
+ "iodine": "Iodine",
+ "water": "Water",
+ "caffeine": "Caffeine"
+ }
}
}
diff --git a/messages/es.json b/messages/es.json
index 41b9df989..5d161c965 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -4238,7 +4238,16 @@
"other": "Otro",
"unknown": "Desconocido"
},
- "subtitle": "Decide qué fuente prevalece cuando dos informan la misma métrica."
+ "subtitle": "Decide qué fuente prevalece cuando dos informan la misma métrica.",
+ "nutrients": {
+ "title": "Ingesta de nutrientes sincronizada",
+ "description": "Totales diarios sincronizados desde Apple Health en los últimos {days} días. Solo lectura: la app de origen es la dueña de los valores.",
+ "empty": "Todavía no se han sincronizado datos de nutrientes.",
+ "latest": "{amount} {unit} el {date}",
+ "daysWithData": "{count} de {days} días",
+ "moduleHint": "La sincronización se controla con el módulo de ingesta de nutrientes.",
+ "moduleHintLink": "Gestionar módulos"
+ }
},
"ai": {
"title": "Proveedor de IA",
@@ -7570,6 +7579,10 @@
"mentalHealth": {
"label": "Bienestar mental",
"description": "Cuestionarios PHQ-9 y GAD-7 opcionales, junto al registro de ánimo. Desactivado por defecto; las respuestas se cifran y se mantienen fuera del Coach de IA."
+ },
+ "nutrients": {
+ "label": "Ingesta de nutrientes",
+ "description": "Guarda los totales diarios de micronutrientes (vitaminas, minerales, agua, cafeína) sincronizados desde Apple Health. Desactivado por defecto."
}
},
"illness": {
@@ -8452,5 +8465,35 @@
"valuesDescription": "Todos los valores registrados de esta métrica.",
"backToMetric": "Volver a {metric}"
}
+ },
+ "nutrients": {
+ "names": {
+ "vitamin_a": "Vitamina A",
+ "thiamin": "Tiamina (B1)",
+ "riboflavin": "Riboflavina (B2)",
+ "niacin": "Niacina (B3)",
+ "pantothenic_acid": "Ácido pantoténico (B5)",
+ "vitamin_b6": "Vitamina B6",
+ "biotin": "Biotina (B7)",
+ "folate": "Folato (B9)",
+ "vitamin_b12": "Vitamina B12",
+ "vitamin_c": "Vitamina C",
+ "vitamin_d": "Vitamina D",
+ "vitamin_e": "Vitamina E",
+ "vitamin_k": "Vitamina K",
+ "calcium": "Calcio",
+ "iron": "Hierro",
+ "magnesium": "Magnesio",
+ "phosphorus": "Fósforo",
+ "zinc": "Zinc",
+ "copper": "Cobre",
+ "manganese": "Manganeso",
+ "selenium": "Selenio",
+ "chromium": "Cromo",
+ "molybdenum": "Molibdeno",
+ "iodine": "Yodo",
+ "water": "Agua",
+ "caffeine": "Cafeína"
+ }
}
}
diff --git a/messages/fr.json b/messages/fr.json
index 1b3c8846e..07324721c 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -4238,7 +4238,16 @@
"other": "Autre",
"unknown": "Inconnu"
},
- "subtitle": "Choisissez la source prioritaire en cas de doublon de mesure."
+ "subtitle": "Choisissez la source prioritaire en cas de doublon de mesure.",
+ "nutrients": {
+ "title": "Apports en nutriments synchronisés",
+ "description": "Totaux quotidiens synchronisés depuis Apple Health sur les {days} derniers jours. Lecture seule : l'app source reste propriétaire des valeurs.",
+ "empty": "Aucune donnée de nutriments synchronisée pour l'instant.",
+ "latest": "{amount} {unit} le {date}",
+ "daysWithData": "{count} jours sur {days}",
+ "moduleHint": "La synchronisation est contrôlée par le module Apports en nutriments.",
+ "moduleHintLink": "Gérer les modules"
+ }
},
"ai": {
"title": "Fournisseur d'IA",
@@ -7570,6 +7579,10 @@
"mentalHealth": {
"label": "Bien-être mental",
"description": "Questionnaires PHQ-9 et GAD-7 facultatifs, en complément du suivi de l'humeur. Désactivé par défaut ; les réponses sont chiffrées et tenues à l'écart du Coach IA."
+ },
+ "nutrients": {
+ "label": "Apports en nutriments",
+ "description": "Enregistre les totaux quotidiens de micronutriments (vitamines, minéraux, eau, caféine) synchronisés depuis Apple Health. Désactivé par défaut."
}
},
"illness": {
@@ -8452,5 +8465,35 @@
"valuesDescription": "Toutes les valeurs enregistrées pour cette mesure.",
"backToMetric": "Retour à {metric}"
}
+ },
+ "nutrients": {
+ "names": {
+ "vitamin_a": "Vitamine A",
+ "thiamin": "Thiamine (B1)",
+ "riboflavin": "Riboflavine (B2)",
+ "niacin": "Niacine (B3)",
+ "pantothenic_acid": "Acide pantothénique (B5)",
+ "vitamin_b6": "Vitamine B6",
+ "biotin": "Biotine (B7)",
+ "folate": "Folates (B9)",
+ "vitamin_b12": "Vitamine B12",
+ "vitamin_c": "Vitamine C",
+ "vitamin_d": "Vitamine D",
+ "vitamin_e": "Vitamine E",
+ "vitamin_k": "Vitamine K",
+ "calcium": "Calcium",
+ "iron": "Fer",
+ "magnesium": "Magnésium",
+ "phosphorus": "Phosphore",
+ "zinc": "Zinc",
+ "copper": "Cuivre",
+ "manganese": "Manganèse",
+ "selenium": "Sélénium",
+ "chromium": "Chrome",
+ "molybdenum": "Molybdène",
+ "iodine": "Iode",
+ "water": "Eau",
+ "caffeine": "Caféine"
+ }
}
}
diff --git a/messages/it.json b/messages/it.json
index f256c5613..82a3fcb09 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -4238,7 +4238,16 @@
"other": "Altro",
"unknown": "Sconosciuto"
},
- "subtitle": "Decidi quale fonte prevale quando due riportano la stessa metrica."
+ "subtitle": "Decidi quale fonte prevale quando due riportano la stessa metrica.",
+ "nutrients": {
+ "title": "Assunzione di nutrienti sincronizzata",
+ "description": "Totali giornalieri sincronizzati da Apple Health negli ultimi {days} giorni. Sola lettura: i valori appartengono all'app di origine.",
+ "empty": "Nessun dato sui nutrienti ancora sincronizzato.",
+ "latest": "{amount} {unit} il {date}",
+ "daysWithData": "{count} giorni su {days}",
+ "moduleHint": "La sincronizzazione è controllata dal modulo Assunzione di nutrienti.",
+ "moduleHintLink": "Gestisci moduli"
+ }
},
"ai": {
"title": "Provider IA",
@@ -7570,6 +7579,10 @@
"mentalHealth": {
"label": "Benessere mentale",
"description": "Questionari PHQ-9 e GAD-7 facoltativi, accanto al diario dell'umore. Disattivato per impostazione predefinita; le risposte sono cifrate e tenute fuori dal Coach IA."
+ },
+ "nutrients": {
+ "label": "Assunzione di nutrienti",
+ "description": "Salva i totali giornalieri di micronutrienti (vitamine, minerali, acqua, caffeina) sincronizzati da Apple Health. Disattivato per impostazione predefinita."
}
},
"illness": {
@@ -8452,5 +8465,35 @@
"valuesDescription": "Tutti i valori registrati per questa metrica.",
"backToMetric": "Torna a {metric}"
}
+ },
+ "nutrients": {
+ "names": {
+ "vitamin_a": "Vitamina A",
+ "thiamin": "Tiamina (B1)",
+ "riboflavin": "Riboflavina (B2)",
+ "niacin": "Niacina (B3)",
+ "pantothenic_acid": "Acido pantotenico (B5)",
+ "vitamin_b6": "Vitamina B6",
+ "biotin": "Biotina (B7)",
+ "folate": "Folati (B9)",
+ "vitamin_b12": "Vitamina B12",
+ "vitamin_c": "Vitamina C",
+ "vitamin_d": "Vitamina D",
+ "vitamin_e": "Vitamina E",
+ "vitamin_k": "Vitamina K",
+ "calcium": "Calcio",
+ "iron": "Ferro",
+ "magnesium": "Magnesio",
+ "phosphorus": "Fosforo",
+ "zinc": "Zinco",
+ "copper": "Rame",
+ "manganese": "Manganese",
+ "selenium": "Selenio",
+ "chromium": "Cromo",
+ "molybdenum": "Molibdeno",
+ "iodine": "Iodio",
+ "water": "Acqua",
+ "caffeine": "Caffeina"
+ }
}
}
diff --git a/messages/pl.json b/messages/pl.json
index ed624c9c1..0e1183b74 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -4238,7 +4238,16 @@
"other": "Inne",
"unknown": "Nieznane"
},
- "subtitle": "Zdecyduj, które źródło wygrywa, gdy dwa podają tę samą metrykę."
+ "subtitle": "Zdecyduj, które źródło wygrywa, gdy dwa podają tę samą metrykę.",
+ "nutrients": {
+ "title": "Zsynchronizowane spożycie składników",
+ "description": "Dzienne sumy z Apple Health z ostatnich {days} dni. Tylko do odczytu — wartości należą do aplikacji źródłowej.",
+ "empty": "Nie zsynchronizowano jeszcze danych o składnikach odżywczych.",
+ "latest": "{amount} {unit} dnia {date}",
+ "daysWithData": "{count} z {days} dni",
+ "moduleHint": "Synchronizacją steruje moduł spożycia składników odżywczych.",
+ "moduleHintLink": "Zarządzaj modułami"
+ }
},
"ai": {
"title": "Dostawca AI",
@@ -7570,6 +7579,10 @@
"mentalHealth": {
"label": "Dobrostan psychiczny",
"description": "Dobrowolne testy PHQ-9 i GAD-7 obok dziennika nastroju. Domyślnie wyłączone; odpowiedzi są szyfrowane i pozostają poza Coachem AI."
+ },
+ "nutrients": {
+ "label": "Spożycie składników odżywczych",
+ "description": "Zapisuje dzienne sumy mikroskładników (witaminy, minerały, woda, kofeina) synchronizowane z Apple Health. Domyślnie wyłączone."
}
},
"illness": {
@@ -8452,5 +8465,35 @@
"valuesDescription": "Wszystkie zapisane wartości tej metryki.",
"backToMetric": "Powrót do {metric}"
}
+ },
+ "nutrients": {
+ "names": {
+ "vitamin_a": "Witamina A",
+ "thiamin": "Tiamina (B1)",
+ "riboflavin": "Ryboflawina (B2)",
+ "niacin": "Niacyna (B3)",
+ "pantothenic_acid": "Kwas pantotenowy (B5)",
+ "vitamin_b6": "Witamina B6",
+ "biotin": "Biotyna (B7)",
+ "folate": "Foliany (B9)",
+ "vitamin_b12": "Witamina B12",
+ "vitamin_c": "Witamina C",
+ "vitamin_d": "Witamina D",
+ "vitamin_e": "Witamina E",
+ "vitamin_k": "Witamina K",
+ "calcium": "Wapń",
+ "iron": "Żelazo",
+ "magnesium": "Magnez",
+ "phosphorus": "Fosfor",
+ "zinc": "Cynk",
+ "copper": "Miedź",
+ "manganese": "Mangan",
+ "selenium": "Selen",
+ "chromium": "Chrom",
+ "molybdenum": "Molibden",
+ "iodine": "Jod",
+ "water": "Woda",
+ "caffeine": "Kofeina"
+ }
}
}
diff --git a/package.json b/package.json
index cb5b2c809..6962a56da 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "healthlog",
- "version": "1.28.33",
+ "version": "1.28.34",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
diff --git a/prisma/migrations/0241_nutrient_intake_days/migration.sql b/prisma/migrations/0241_nutrient_intake_days/migration.sql
new file mode 100644
index 000000000..ba42cd536
--- /dev/null
+++ b/prisma/migrations/0241_nutrient_intake_days/migration.sql
@@ -0,0 +1,37 @@
+-- v1.28 — micronutrient intake daily totals (opt-in `nutrients` module).
+--
+-- One row per (user, local day, nutrient): vitamins, minerals, water and
+-- caffeine as supplement-style day totals synced from Apple Health. The
+-- composite PK is the idempotency key — an iOS re-post of a grown day
+-- total replaces the row in place (last-writer-wins day-total contract),
+-- so there is no externalId column and no soft delete. `day` is a
+-- YYYY-MM-DD TEXT key anchored to the user's IANA timezone (the
+-- EnvironmentContext / MedicationComplianceRollup day-key precedent);
+-- `nutrient` is a bare TEXT code enforced in code against the closed
+-- catalog (`src/lib/nutrients/catalog.ts`) so adding a code never needs
+-- a migration. `unit` is denormalised from the catalog on purpose: rows
+-- stay self-describing in exports even if the catalog ever drifts.
+CREATE TABLE "nutrient_intake_days" (
+ "user_id" TEXT NOT NULL,
+ "day" TEXT NOT NULL,
+ "nutrient" TEXT NOT NULL,
+ "amount" DOUBLE PRECISION NOT NULL,
+ "unit" TEXT NOT NULL,
+ "source" TEXT NOT NULL DEFAULT 'APPLE_HEALTH',
+ "external_source_version" TEXT,
+ "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" TIMESTAMPTZ(3) NOT NULL,
+
+ CONSTRAINT "nutrient_intake_days_pkey" PRIMARY KEY ("user_id", "day", "nutrient")
+);
+
+-- Read path: the settings-card / read-endpoint window scan per user, and
+-- the later per-nutrient series fetch (day DESC for "latest first").
+CREATE INDEX "nutrient_intake_days_user_id_nutrient_day_idx"
+ ON "nutrient_intake_days" ("user_id", "nutrient", "day" DESC);
+
+-- Account deletion cascades; rows are worthless without their owner.
+ALTER TABLE "nutrient_intake_days"
+ ADD CONSTRAINT "nutrient_intake_days_user_id_fkey"
+ FOREIGN KEY ("user_id") REFERENCES "users"("id")
+ ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index ca2b01fd0..b12247082 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -805,6 +805,10 @@ model User {
// that win over the home location for the days they cover.
environmentContexts EnvironmentContext[]
environmentTravelLocations EnvironmentTravelLocation[]
+ // v1.28 — micronutrient intake daily totals synced from Apple Health
+ // (vitamins, minerals, water, caffeine). Opt-in `nutrients` module, off
+ // by default; the ingest route refuses while the module is off.
+ nutrientIntakeDays NutrientIntakeDay[]
// v1.25 (W-DOCS-IN) — inbound clinical documents (doctor reports /
// discharge letters) the user uploads. The raw document is encrypted at
// rest; extracted facts land in a review-then-confirm staging area
@@ -6201,3 +6205,43 @@ model DocumentThumbnail {
@@index([userId])
@@map("document_thumbnails")
}
+
+// ─── Nutrient intake (v1.28) ───────────────────────────────────────
+//
+// Micronutrient daily totals synced from Apple Health — vitamins,
+// minerals, water, caffeine as supplement-style "substances taken",
+// never a food diary (energy / macros are out of scope by directive).
+// One row per (user, local day, nutrient); the composite PK IS the
+// idempotency key, so an iOS re-post of a grown day total replaces the
+// row in place (last-writer-wins day-total contract — the `stats:`
+// overwrite semantics without externalId string parsing). `nutrient`
+// is a bare string enforced in code against the closed catalog in
+// `src/lib/nutrients/catalog.ts` (the IntegrationStatus precedent: no
+// migration to add a code). No soft delete: rows are source-owned day
+// totals; replace-on-repost is the correction mechanism and account
+// deletion cascades. Behind the opt-in `nutrients` module
+// (refuse-ingest-when-off).
+
+model NutrientIntakeDay {
+ userId String @map("user_id")
+ /// YYYY-MM-DD anchored to the user's IANA timezone (iOS-computed —
+ /// the same day-key contract as the `stats:` externalIds).
+ day String
+ /// Closed catalog code ("vitamin_d", "magnesium", "water", ...).
+ nutrient String
+ /// Daily total in the catalog's canonical unit.
+ amount Float
+ /// Canonical unit echoed from the catalog ("mg" | "ug" | "ml") —
+ /// denormalised on purpose: rows stay self-describing in exports
+ /// even if the catalog ever drifts.
+ unit String
+ source String @default("APPLE_HEALTH")
+ externalSourceVersion String? @map("external_source_version")
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ @@id([userId, day, nutrient])
+ @@index([userId, nutrient, day(sort: Desc)])
+ @@map("nutrient_intake_days")
+}
diff --git a/public/sw.js b/public/sw.js
index 4d35ea43d..8cef7facb 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
- /* @sw-version-fallback */ "v1.28.33";
+ /* @sw-version-fallback */ "v1.28.34";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
diff --git a/src/app/api/__tests__/module-route-gate-inventory.test.ts b/src/app/api/__tests__/module-route-gate-inventory.test.ts
index d6989da1b..2487d7d87 100644
--- a/src/app/api/__tests__/module-route-gate-inventory.test.ts
+++ b/src/app/api/__tests__/module-route-gate-inventory.test.ts
@@ -111,6 +111,13 @@ const MODULE_ROUTE_TREES: ReadonlyArray = [
// ungated insights AI route fails this test BY NAME rather than leaking the
// surface over a Bearer token when the account turned insights off.
"src/app/api/insights",
+ // v1.28 — the nutrient-intake sync (opt-in `nutrients` module). Unlike the
+ // data-layer-exempt siblings this domain is REFUSE-INGEST-WHEN-OFF (the
+ // mental-health posture): both the batch ingest and the window-summary
+ // read call `requireModuleEnabled(user.id, "nutrients")` directly, so a
+ // phone whose user never opted in cannot land rows server-side. Walking
+ // the tree means a NEW ungated nutrients route fails BY NAME.
+ "src/app/api/nutrients",
];
/**
diff --git a/src/app/api/nutrients/__tests__/route.test.ts b/src/app/api/nutrients/__tests__/route.test.ts
new file mode 100644
index 000000000..a3ae3fac1
--- /dev/null
+++ b/src/app/api/nutrients/__tests__/route.test.ts
@@ -0,0 +1,146 @@
+/**
+ * v1.28 — `GET /api/nutrients` window-summary contract tests.
+ *
+ * Pins the read shape the settings card consumes: catalog-ordered
+ * per-nutrient summaries (latest day + total, days-with-data), the
+ * module gate, and the `days` query validation.
+ */
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { NextRequest } from "next/server";
+
+vi.mock("@/lib/db", () => ({
+ prisma: {
+ nutrientIntakeDay: {
+ findMany: vi.fn(),
+ },
+ },
+}));
+
+vi.mock("@/lib/modules/gate", () => ({
+ requireModuleEnabled: vi.fn(),
+}));
+
+vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
+vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
+vi.mock("@/lib/db-compat", () => ({
+ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
+}));
+vi.mock("next/headers", () => ({
+ headers: vi.fn(async () => ({ get: () => null })),
+ cookies: vi.fn(async () => ({
+ get: () => undefined,
+ set: () => {},
+ delete: () => {},
+ })),
+}));
+
+import { GET } from "../route";
+import { prisma } from "@/lib/db";
+import { apiError } from "@/lib/api-response";
+import { getSession } from "@/lib/auth/session";
+import { requireModuleEnabled } from "@/lib/modules/gate";
+
+const SESSION_OK = {
+ session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
+ user: { id: "user-1", username: "tester", role: "USER" as const },
+};
+
+function getReq(query = ""): NextRequest {
+ return new NextRequest(`http://localhost/api/nutrients${query}`);
+}
+
+interface OverviewResponse {
+ data: {
+ windowDays: number;
+ nutrients: Array<{
+ nutrient: string;
+ unit: string;
+ latestDay: string;
+ latestAmount: number;
+ daysWithData: number;
+ }>;
+ };
+}
+
+beforeEach(() => {
+ vi.resetAllMocks();
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
+ vi.mocked(prisma.nutrientIntakeDay.findMany).mockResolvedValue([] as never);
+});
+
+describe("GET /api/nutrients", () => {
+ it("refuses with the 403 module.disabled envelope when the module is off", async () => {
+ vi.mocked(requireModuleEnabled).mockResolvedValue({
+ enabled: false,
+ response: apiError('Module "nutrients" is not enabled', 403, {
+ errorCode: "module.disabled",
+ module: "nutrients",
+ }),
+ });
+ const res = await GET(getReq());
+ expect(res.status).toBe(403);
+ const body = (await res.json()) as { meta?: { errorCode?: string } };
+ expect(body.meta?.errorCode).toBe("module.disabled");
+ expect(prisma.nutrientIntakeDay.findMany).not.toHaveBeenCalled();
+ });
+
+ it("rejects an out-of-range days value with 422", async () => {
+ for (const bad of ["0", "366", "abc"]) {
+ const res = await GET(getReq(`?days=${bad}`));
+ expect(res.status, `days=${bad}`).toBe(422);
+ }
+ });
+
+ it("defaults to a 14-day window and returns an empty list for a fresh account", async () => {
+ const res = await GET(getReq());
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as OverviewResponse;
+ expect(body.data).toEqual({ windowDays: 14, nutrients: [] });
+ });
+
+ it("folds rows into catalog-ordered summaries with latest day/amount and day counts", async () => {
+ // Rows arrive nutrient ASC, day DESC (the route's orderBy) — the
+ // fold takes the first row per nutrient as the latest.
+ vi.mocked(prisma.nutrientIntakeDay.findMany).mockResolvedValue([
+ { nutrient: "caffeine", unit: "mg", day: "2026-07-13", amount: 310 },
+ { nutrient: "caffeine", unit: "mg", day: "2026-07-12", amount: 250 },
+ { nutrient: "caffeine", unit: "mg", day: "2026-07-11", amount: 400 },
+ { nutrient: "vitamin_d", unit: "ug", day: "2026-07-13", amount: 22.5 },
+ { nutrient: "vitamin_d", unit: "ug", day: "2026-07-10", amount: 20 },
+ ] as never);
+
+ const res = await GET(getReq("?days=7"));
+ const body = (await res.json()) as OverviewResponse;
+ expect(body.data.windowDays).toBe(7);
+ // Catalog order: vitamin_d (vitamins block) before caffeine (tail).
+ expect(body.data.nutrients).toEqual([
+ {
+ nutrient: "vitamin_d",
+ unit: "ug",
+ latestDay: "2026-07-13",
+ latestAmount: 22.5,
+ daysWithData: 2,
+ },
+ {
+ nutrient: "caffeine",
+ unit: "mg",
+ latestDay: "2026-07-13",
+ latestAmount: 310,
+ daysWithData: 3,
+ },
+ ]);
+ });
+
+ it("scopes the query to the authenticated user and the window floor", async () => {
+ await GET(getReq("?days=14"));
+ expect(prisma.nutrientIntakeDay.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: expect.objectContaining({
+ userId: "user-1",
+ day: { gte: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/) },
+ }),
+ }),
+ );
+ });
+});
diff --git a/src/app/api/nutrients/batch/__tests__/route.test.ts b/src/app/api/nutrients/batch/__tests__/route.test.ts
new file mode 100644
index 000000000..e1803183f
--- /dev/null
+++ b/src/app/api/nutrients/batch/__tests__/route.test.ts
@@ -0,0 +1,354 @@
+/**
+ * v1.28 — `POST /api/nutrients/batch` contract tests.
+ *
+ * Pins the ingest posture: module gate first (403 `module.disabled`),
+ * per-entry skip guards (unit mismatch / plausibility / day key), the
+ * insert-vs-update statuses off the composite-PK probe, and the batch
+ * envelope errors (`too_large`, multi-issue `invalid`).
+ */
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { NextRequest } from "next/server";
+
+vi.mock("@/lib/db", () => ({
+ prisma: {
+ nutrientIntakeDay: {
+ findUnique: vi.fn(),
+ upsert: vi.fn(),
+ },
+ },
+}));
+
+vi.mock("@/lib/modules/gate", () => ({
+ requireModuleEnabled: vi.fn(),
+}));
+
+vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
+vi.mock("@/lib/auth/audit", () => ({
+ auditLog: vi.fn().mockResolvedValue(undefined),
+}));
+vi.mock("@/lib/rate-limit", () => ({
+ checkRateLimit: vi.fn(),
+ rateLimitHeaders: () => ({}),
+}));
+vi.mock("@/lib/idempotency", () => ({
+ withIdempotency:
+ (fn: (...args: Args) => Promise) =>
+ (...args: Args) =>
+ fn(...args),
+}));
+vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
+vi.mock("@/lib/db-compat", () => ({
+ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
+}));
+vi.mock("next/headers", () => ({
+ headers: vi.fn(async () => ({ get: () => null })),
+ cookies: vi.fn(async () => ({
+ get: () => undefined,
+ set: () => {},
+ delete: () => {},
+ })),
+}));
+
+import { POST } from "../route";
+import { prisma } from "@/lib/db";
+import { apiError } from "@/lib/api-response";
+import { getSession } from "@/lib/auth/session";
+import { checkRateLimit } from "@/lib/rate-limit";
+import { requireModuleEnabled } from "@/lib/modules/gate";
+import { auditLog } from "@/lib/auth/audit";
+
+const SESSION_OK = {
+ session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
+ user: { id: "user-1", username: "tester", role: "USER" as const },
+};
+
+function postReq(body: unknown): NextRequest {
+ return new NextRequest("http://localhost/api/nutrients/batch", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ });
+}
+
+/** Yesterday's UTC day key — always a valid, in-window day. */
+function recentDay(offsetDays = 1): string {
+ return new Date(Date.now() - offsetDays * 24 * 60 * 60 * 1000)
+ .toISOString()
+ .slice(0, 10);
+}
+
+interface BatchResponse {
+ data: {
+ processed: number;
+ inserted: number;
+ updated: number;
+ skipped: Array<{ index: number; reason: string }>;
+ entries: Array<{ index: number; status: string; reason?: string }>;
+ };
+}
+
+beforeEach(() => {
+ vi.resetAllMocks();
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
+ vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
+ vi.mocked(auditLog).mockResolvedValue(undefined as never);
+ vi.mocked(prisma.nutrientIntakeDay.findUnique).mockResolvedValue(null);
+ vi.mocked(prisma.nutrientIntakeDay.upsert).mockResolvedValue({} as never);
+});
+
+describe("POST /api/nutrients/batch — module gate", () => {
+ it("refuses ingest with the 403 module.disabled envelope when the module is off", async () => {
+ vi.mocked(requireModuleEnabled).mockResolvedValue({
+ enabled: false,
+ response: apiError('Module "nutrients" is not enabled', 403, {
+ errorCode: "module.disabled",
+ module: "nutrients",
+ }),
+ });
+
+ const res = await POST(
+ postReq({
+ entries: [
+ { day: recentDay(), nutrient: "vitamin_d", unit: "ug", amount: 20 },
+ ],
+ }),
+ );
+ expect(res.status).toBe(403);
+ const body = (await res.json()) as {
+ data: null;
+ meta?: { errorCode?: string; module?: string };
+ };
+ expect(body.data).toBeNull();
+ expect(body.meta?.errorCode).toBe("module.disabled");
+ expect(body.meta?.module).toBe("nutrients");
+ expect(prisma.nutrientIntakeDay.upsert).not.toHaveBeenCalled();
+ });
+});
+
+describe("POST /api/nutrients/batch — envelope errors", () => {
+ it("rejects a 501-entry batch with 422 nutrient.batch.too_large before Zod", async () => {
+ const entries = Array.from({ length: 501 }, () => ({
+ day: recentDay(),
+ nutrient: "vitamin_c",
+ unit: "mg",
+ amount: 100,
+ }));
+ const res = await POST(postReq({ entries }));
+ expect(res.status).toBe(422);
+ const body = (await res.json()) as { meta?: { errorCode?: string } };
+ expect(body.meta?.errorCode).toBe("nutrient.batch.too_large");
+ });
+
+ it("surfaces multiple simultaneous Zod issues as one 422 nutrient.batch.invalid", async () => {
+ const res = await POST(
+ postReq({
+ entries: [
+ { day: "not-a-day", nutrient: "vitamin_d", unit: "ug", amount: 20 },
+ { day: recentDay(), nutrient: "kryptonite", unit: "mg", amount: 1 },
+ { day: recentDay(), nutrient: "iron", unit: "mg", amount: -5 },
+ ],
+ }),
+ );
+ expect(res.status).toBe(422);
+ const body = (await res.json()) as {
+ details: { issues: unknown[] };
+ meta?: { errorCode?: string };
+ };
+ expect(body.meta?.errorCode).toBe("nutrient.batch.invalid");
+ expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
+ });
+
+ it("returns 429 when the per-user rate bucket is exhausted", async () => {
+ vi.mocked(checkRateLimit).mockResolvedValue({ allowed: false } as never);
+ const res = await POST(
+ postReq({
+ entries: [
+ { day: recentDay(), nutrient: "water", unit: "ml", amount: 1500 },
+ ],
+ }),
+ );
+ expect(res.status).toBe(429);
+ });
+});
+
+describe("POST /api/nutrients/batch — per-entry skip guards", () => {
+ it("skips a unit mismatch (µg posted as mg) without touching the store", async () => {
+ const res = await POST(
+ postReq({
+ entries: [
+ // vitamin_d is canonically µg; posting mg is the 1000× hazard.
+ { day: recentDay(), nutrient: "vitamin_d", unit: "mg", amount: 20 },
+ ],
+ }),
+ );
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as BatchResponse;
+ expect(body.data.entries[0]).toEqual({
+ index: 0,
+ status: "skipped",
+ reason: "unit_mismatch",
+ });
+ expect(body.data.skipped).toEqual([{ index: 0, reason: "unit_mismatch" }]);
+ expect(prisma.nutrientIntakeDay.upsert).not.toHaveBeenCalled();
+ });
+
+ it("skips a value above the plausibility cap", async () => {
+ const res = await POST(
+ postReq({
+ entries: [
+ // caffeine cap is 2000 mg.
+ { day: recentDay(), nutrient: "caffeine", unit: "mg", amount: 2001 },
+ ],
+ }),
+ );
+ const body = (await res.json()) as BatchResponse;
+ expect(body.data.entries[0].status).toBe("skipped");
+ expect(body.data.entries[0].reason).toBe("value_out_of_range");
+ expect(prisma.nutrientIntakeDay.upsert).not.toHaveBeenCalled();
+ });
+
+ it("skips an impossible calendar day and a far-future day as day_invalid", async () => {
+ const res = await POST(
+ postReq({
+ entries: [
+ // Regex-shaped but not a real date.
+ { day: "2026-02-31", nutrient: "iron", unit: "mg", amount: 10 },
+ // Beyond tomorrow in every IANA timezone.
+ { day: "2999-01-01", nutrient: "iron", unit: "mg", amount: 10 },
+ ],
+ }),
+ );
+ const body = (await res.json()) as BatchResponse;
+ expect(body.data.entries.map((e) => e.reason)).toEqual([
+ "day_invalid",
+ "day_invalid",
+ ]);
+ expect(prisma.nutrientIntakeDay.upsert).not.toHaveBeenCalled();
+ });
+
+ it("a skipped entry never fails the batch — siblings still land", async () => {
+ const res = await POST(
+ postReq({
+ entries: [
+ { day: recentDay(), nutrient: "vitamin_d", unit: "mg", amount: 20 },
+ { day: recentDay(), nutrient: "magnesium", unit: "mg", amount: 300 },
+ ],
+ }),
+ );
+ const body = (await res.json()) as BatchResponse;
+ expect(body.data.processed).toBe(2);
+ expect(body.data.inserted).toBe(1);
+ expect(body.data.skipped).toHaveLength(1);
+ expect(prisma.nutrientIntakeDay.upsert).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe("POST /api/nutrients/batch — upsert semantics", () => {
+ it("reports inserted for a fresh (day, nutrient) and writes the catalog's canonical unit", async () => {
+ const day = recentDay();
+ const res = await POST(
+ postReq({
+ entries: [
+ {
+ day,
+ nutrient: "vitamin_d",
+ unit: "ug",
+ amount: 22.5,
+ externalSourceVersion: "yazio-9.9",
+ },
+ ],
+ }),
+ );
+ const body = (await res.json()) as BatchResponse;
+ expect(body.data.inserted).toBe(1);
+ expect(body.data.updated).toBe(0);
+ expect(body.data.entries[0].status).toBe("inserted");
+
+ expect(prisma.nutrientIntakeDay.upsert).toHaveBeenCalledWith({
+ where: {
+ userId_day_nutrient: {
+ userId: "user-1",
+ day,
+ nutrient: "vitamin_d",
+ },
+ },
+ create: {
+ userId: "user-1",
+ day,
+ nutrient: "vitamin_d",
+ amount: 22.5,
+ unit: "ug",
+ externalSourceVersion: "yazio-9.9",
+ },
+ update: {
+ amount: 22.5,
+ unit: "ug",
+ externalSourceVersion: "yazio-9.9",
+ },
+ });
+ });
+
+ it("reports updated (never duplicate) when the composite key already exists", async () => {
+ vi.mocked(prisma.nutrientIntakeDay.findUnique).mockResolvedValue({
+ userId: "user-1",
+ } as never);
+
+ const res = await POST(
+ postReq({
+ entries: [
+ { day: recentDay(), nutrient: "caffeine", unit: "mg", amount: 310 },
+ ],
+ }),
+ );
+ const body = (await res.json()) as BatchResponse;
+ expect(body.data.inserted).toBe(0);
+ expect(body.data.updated).toBe(1);
+ expect(body.data.entries[0].status).toBe("updated");
+ expect(
+ body.data.entries.some((e) => e.status === ("duplicate" as string)),
+ ).toBe(false);
+ });
+
+ it("writes the audit-ledger breadcrumb only when at least one row landed", async () => {
+ await POST(
+ postReq({
+ entries: [
+ { day: recentDay(), nutrient: "vitamin_d", unit: "mg", amount: 20 },
+ ],
+ }),
+ );
+ expect(auditLog).not.toHaveBeenCalled();
+
+ await POST(
+ postReq({
+ entries: [
+ { day: recentDay(), nutrient: "water", unit: "ml", amount: 1800 },
+ ],
+ }),
+ );
+ expect(auditLog).toHaveBeenCalledWith(
+ "nutrient.batch.ingest",
+ expect.objectContaining({ userId: "user-1" }),
+ );
+ });
+
+ it("a thrown upsert marks only that entry skipped upsert_failed", async () => {
+ vi.mocked(prisma.nutrientIntakeDay.upsert)
+ .mockRejectedValueOnce(new Error("boom"))
+ .mockResolvedValueOnce({} as never);
+
+ const res = await POST(
+ postReq({
+ entries: [
+ { day: recentDay(), nutrient: "zinc", unit: "mg", amount: 10 },
+ { day: recentDay(), nutrient: "iron", unit: "mg", amount: 12 },
+ ],
+ }),
+ );
+ const body = (await res.json()) as BatchResponse;
+ expect(body.data.entries[0].status).toBe("skipped");
+ expect(body.data.entries[1].status).toBe("inserted");
+ expect(body.data.inserted).toBe(1);
+ });
+});
diff --git a/src/app/api/nutrients/batch/route.ts b/src/app/api/nutrients/batch/route.ts
new file mode 100644
index 000000000..c952fa911
--- /dev/null
+++ b/src/app/api/nutrients/batch/route.ts
@@ -0,0 +1,248 @@
+/**
+ * `POST /api/nutrients/batch` — micronutrient day-total ingest (v1.28).
+ *
+ * iOS posts daily totals for the closed 26-code catalog (vitamins,
+ * minerals, water, caffeine) computed via `HKStatisticsCollectionQuery`
+ * day-anchored cumulative sums; `day` arrives as YYYY-MM-DD in the
+ * user's IANA timezone (the `stats:` day-key contract — the server
+ * trusts the string after regex + calendar sanity, no re-derivation).
+ *
+ * Upsert key = the composite PK (userId, day, nutrient). Re-post
+ * replaces amount / unit / externalSourceVersion — last-writer-wins,
+ * the day-total contract; per-entry status is `updated` then, never
+ * `duplicate`. Per-entry failures come back `skipped` with a reason
+ * (`unit_mismatch` | `value_out_of_range` | `day_invalid` |
+ * `upsert_failed`), never a batch failure — the measurements-batch
+ * posture.
+ *
+ * Module gate FIRST: the opt-in `nutrients` module (default off)
+ * refuses ingest with the 403 `module.disabled` envelope, so a phone
+ * whose user never opted in cannot land rows server-side
+ * (refuse-ingest posture, mirroring mental health — not surface-only).
+ */
+import { NextRequest } from "next/server";
+
+import { prisma } from "@/lib/db";
+import { apiHandler, requireAuth } from "@/lib/api-handler";
+import { annotate } from "@/lib/logging/context";
+import { auditLog } from "@/lib/auth/audit";
+import {
+ apiError,
+ apiSuccess,
+ getClientIp,
+ returnAllZodIssues,
+ safeJson,
+} from "@/lib/api-response";
+import { withIdempotency } from "@/lib/idempotency";
+import { checkRateLimit } from "@/lib/rate-limit";
+import { requireModuleEnabled } from "@/lib/modules/gate";
+import { NUTRIENT_CATALOG } from "@/lib/nutrients/catalog";
+import {
+ MAX_NUTRIENT_ENTRIES_PER_BATCH,
+ nutrientBatchSchema,
+} from "@/lib/validations/nutrients";
+
+const BATCH_RATE_LIMIT_MAX = 60;
+const BATCH_RATE_LIMIT_WINDOW_MS = 60 * 1000;
+
+/**
+ * Calendar sanity for an already regex-shaped YYYY-MM-DD key: reject
+ * impossible dates (2026-02-31) via a UTC round-trip.
+ */
+function isRealCalendarDay(day: string): boolean {
+ const [y, m, d] = day.split("-").map(Number);
+ const dt = new Date(Date.UTC(y, m - 1, d));
+ return (
+ dt.getUTCFullYear() === y &&
+ dt.getUTCMonth() === m - 1 &&
+ dt.getUTCDate() === d
+ );
+}
+
+/**
+ * Upper day bound with timezone slack: "tomorrow" in the most-ahead
+ * IANA zone (UTC+14) is at most the UTC calendar date + 2 days, so a
+ * key beyond that cannot be a legitimate local day and is corruption.
+ */
+function maxAcceptableDay(now: Date): string {
+ const limit = new Date(now.getTime() + 2 * 24 * 60 * 60 * 1000);
+ return limit.toISOString().slice(0, 10);
+}
+
+type EntryStatus = "inserted" | "updated" | "skipped";
+interface EntryResult {
+ index: number;
+ status: EntryStatus;
+ reason?: string;
+}
+
+export const POST = apiHandler(withIdempotency<[NextRequest]>(postBatch));
+
+async function postBatch(request: NextRequest): Promise {
+ const { user } = await requireAuth();
+
+ // Module gate first: a disabled module must refuse before any body
+ // work, so iOS can branch on the 403 `module.disabled` errorCode and
+ // stop syncing rather than retry.
+ const gate = await requireModuleEnabled(user.id, "nutrients");
+ if (!gate.enabled) return gate.response;
+
+ const rl = await checkRateLimit(
+ `nutrients:batch:${user.id}`,
+ BATCH_RATE_LIMIT_MAX,
+ BATCH_RATE_LIMIT_WINDOW_MS,
+ );
+ if (!rl.allowed) {
+ return apiError("Too many batch submissions, try again later", 429);
+ }
+
+ const { data: rawBody, error: jsonError } = await safeJson(request, {
+ maxBytes: 1024 * 1024,
+ });
+ if (jsonError) return jsonError;
+
+ if (
+ typeof rawBody === "object" &&
+ rawBody !== null &&
+ "entries" in rawBody &&
+ Array.isArray((rawBody as { entries: unknown }).entries) &&
+ (rawBody as { entries: unknown[] }).entries.length >
+ MAX_NUTRIENT_ENTRIES_PER_BATCH
+ ) {
+ return apiError(
+ `Batch exceeds the ${MAX_NUTRIENT_ENTRIES_PER_BATCH}-entry limit`,
+ 422,
+ { errorCode: "nutrient.batch.too_large" },
+ );
+ }
+
+ const parsed = nutrientBatchSchema.safeParse(rawBody);
+ if (!parsed.success) {
+ annotate({
+ action: { name: "nutrient.batch.validation-failed" },
+ meta: { issue_count: parsed.error.issues.length },
+ });
+ return returnAllZodIssues(parsed.error, 422, {
+ errorCode: "nutrient.batch.invalid",
+ });
+ }
+
+ const { entries } = parsed.data;
+ const dayCeiling = maxAcceptableDay(new Date());
+
+ const results: EntryResult[] = [];
+ const skipped: Array<{ index: number; reason: string }> = [];
+ let inserted = 0;
+ let updated = 0;
+
+ for (let i = 0; i < entries.length; i++) {
+ const entry = entries[i];
+ const definition = NUTRIENT_CATALOG[entry.nutrient];
+
+ // The wire carries the unit deliberately: a µg/mg confusion is a
+ // silent 1000× corruption; anything but the catalog's canonical
+ // unit skips the entry rather than converting.
+ if (entry.unit !== definition.unit) {
+ skipped.push({ index: i, reason: "unit_mismatch" });
+ results.push({ index: i, status: "skipped", reason: "unit_mismatch" });
+ continue;
+ }
+
+ if (entry.amount > definition.plausibleDailyMax) {
+ skipped.push({ index: i, reason: "value_out_of_range" });
+ results.push({
+ index: i,
+ status: "skipped",
+ reason: "value_out_of_range",
+ });
+ continue;
+ }
+
+ if (!isRealCalendarDay(entry.day) || entry.day > dayCeiling) {
+ skipped.push({ index: i, reason: "day_invalid" });
+ results.push({ index: i, status: "skipped", reason: "day_invalid" });
+ continue;
+ }
+
+ try {
+ const key = {
+ userId_day_nutrient: {
+ userId: user.id,
+ day: entry.day,
+ nutrient: entry.nutrient,
+ },
+ };
+
+ // Probe-then-upsert so the response reliably distinguishes
+ // `inserted` from `updated` (the mood-bulk pattern; two round
+ // trips per entry is fine under the 500-entry cap).
+ const existing = await prisma.nutrientIntakeDay.findUnique({
+ where: key,
+ select: { userId: true },
+ });
+
+ await prisma.nutrientIntakeDay.upsert({
+ where: key,
+ create: {
+ userId: user.id,
+ day: entry.day,
+ nutrient: entry.nutrient,
+ amount: entry.amount,
+ unit: definition.unit,
+ externalSourceVersion: entry.externalSourceVersion ?? null,
+ },
+ update: {
+ // Last-writer-wins on the day total: iOS re-posts the current
+ // and previous local day on every sync as the totals grow.
+ amount: entry.amount,
+ unit: definition.unit,
+ externalSourceVersion: entry.externalSourceVersion ?? null,
+ },
+ });
+
+ if (existing) {
+ updated += 1;
+ results.push({ index: i, status: "updated" });
+ } else {
+ inserted += 1;
+ results.push({ index: i, status: "inserted" });
+ }
+ } catch (err: unknown) {
+ const reason =
+ err instanceof Error ? err.message.slice(0, 120) : "upsert_failed";
+ skipped.push({ index: i, reason });
+ results.push({ index: i, status: "skipped", reason });
+ }
+ }
+
+ if (inserted + updated > 0) {
+ await auditLog("nutrient.batch.ingest", {
+ userId: user.id,
+ ipAddress: getClientIp(request),
+ details: {
+ processed: entries.length,
+ inserted,
+ updated,
+ skipped: skipped.length,
+ },
+ });
+ }
+
+ annotate({
+ action: { name: "nutrient.batch.ingest" },
+ meta: {
+ processed: entries.length,
+ inserted,
+ updated,
+ skipped: skipped.length,
+ },
+ });
+
+ return apiSuccess({
+ processed: entries.length,
+ inserted,
+ updated,
+ skipped,
+ entries: results,
+ });
+}
diff --git a/src/app/api/nutrients/route.ts b/src/app/api/nutrients/route.ts
new file mode 100644
index 000000000..0204ab006
--- /dev/null
+++ b/src/app/api/nutrients/route.ts
@@ -0,0 +1,97 @@
+/**
+ * `GET /api/nutrients?days=N` — synced-nutrient window summary (v1.28).
+ *
+ * Feeds the read-only settings card (Settings → Sources): per nutrient
+ * with data inside the window, the latest synced day + total and the
+ * count of days carrying data — the smallest honest surface for "what
+ * does the server hold". Catalog order, no pagination (≤ 26 rows).
+ * Module-gated like the ingest path: 403 `module.disabled` when the
+ * opt-in `nutrients` module is off. `userId` is narrowed from auth.
+ */
+import { NextRequest } from "next/server";
+
+import { prisma } from "@/lib/db";
+import { apiHandler, requireAuth } from "@/lib/api-handler";
+import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
+import { annotate } from "@/lib/logging/context";
+import { requireModuleEnabled } from "@/lib/modules/gate";
+import { NUTRIENT_CODES, isNutrientCode } from "@/lib/nutrients/catalog";
+import { nutrientOverviewQuerySchema } from "@/lib/validations/nutrients";
+
+export const dynamic = "force-dynamic";
+
+export const GET = apiHandler(async (request: NextRequest) => {
+ const { user } = await requireAuth();
+
+ const gate = await requireModuleEnabled(user.id, "nutrients");
+ if (!gate.enabled) return gate.response;
+
+ const parsed = nutrientOverviewQuerySchema.safeParse({
+ days: request.nextUrl.searchParams.get("days") ?? undefined,
+ });
+ if (!parsed.success) {
+ return returnAllZodIssues(parsed.error, 422, {
+ errorCode: "nutrient.read.invalid",
+ });
+ }
+ const { days } = parsed.data;
+
+ // Window floor as a UTC day key. The stored `day` is a local-timezone
+ // key, so the UTC floor is off by at most one calendar day at the
+ // window edge — fine for a "last N days" summary card.
+ const since = new Date(Date.now() - (days - 1) * 24 * 60 * 60 * 1000)
+ .toISOString()
+ .slice(0, 10);
+
+ const rows = await prisma.nutrientIntakeDay.findMany({
+ where: { userId: user.id, day: { gte: since } },
+ orderBy: [{ nutrient: "asc" }, { day: "desc" }],
+ select: { nutrient: true, unit: true, day: true, amount: true },
+ });
+
+ // Fold to one summary row per nutrient. Rows arrive day-DESC inside
+ // each nutrient, so the first row seen per code is the latest.
+ const byCode = new Map<
+ string,
+ { unit: string; latestDay: string; latestAmount: number; days: number }
+ >();
+ for (const row of rows) {
+ const existing = byCode.get(row.nutrient);
+ if (existing) {
+ existing.days += 1;
+ } else {
+ byCode.set(row.nutrient, {
+ unit: row.unit,
+ latestDay: row.day,
+ latestAmount: row.amount,
+ days: 1,
+ });
+ }
+ }
+
+ // Catalog order; a stored code that ever fell out of the catalog is
+ // dropped from the card rather than crashing the label lookup.
+ const nutrients = NUTRIENT_CODES.filter(
+ (code) => isNutrientCode(code) && byCode.has(code),
+ ).map((code) => {
+ const summary = byCode.get(code)!;
+ return {
+ nutrient: code,
+ unit: summary.unit,
+ latestDay: summary.latestDay,
+ latestAmount: summary.latestAmount,
+ daysWithData: summary.days,
+ };
+ });
+
+ annotate({
+ action: { name: "nutrient.intake.read" },
+ meta: {
+ window_days: days,
+ nutrient_count: nutrients.length,
+ row_count: rows.length,
+ },
+ });
+
+ return apiSuccess({ windowDays: days, nutrients });
+});
diff --git a/src/components/settings/__tests__/nutrient-intake-card.test.tsx b/src/components/settings/__tests__/nutrient-intake-card.test.tsx
new file mode 100644
index 000000000..120c9c141
--- /dev/null
+++ b/src/components/settings/__tests__/nutrient-intake-card.test.tsx
@@ -0,0 +1,108 @@
+/**
+ * v1.28 — `` render contract.
+ *
+ * The read-only Settings → Sources list for the opt-in `nutrients`
+ * module: renders NOTHING while the module is off (the Modules hub is
+ * the consent surface), and a flat name / latest-total / days-count
+ * list when it is on. Test strategy mirrors `modules-section.test.tsx`:
+ * mock `@tanstack/react-query` + `useAuth`, render under SSR.
+ */
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { I18nProvider } from "@/lib/i18n/context";
+import type { AuthUser } from "@/hooks/use-auth";
+
+interface QueryState {
+ data?: unknown;
+ isLoading: boolean;
+}
+let queryState: QueryState = { data: undefined, isLoading: false };
+
+vi.mock("@tanstack/react-query", () => ({
+ useQuery: () => queryState,
+}));
+
+const authSpy = vi.fn<() => { user: AuthUser | null }>();
+vi.mock("@/hooks/use-auth", async () => {
+ const actual =
+ await vi.importActual(
+ "@/hooks/use-auth",
+ );
+ return { ...actual, useAuth: () => authSpy() };
+});
+
+import { NutrientIntakeCard } from "../nutrient-intake-card";
+
+function user(modules: Record): AuthUser {
+ return { id: "user-1", modules } as unknown as AuthUser;
+}
+
+function render(): string {
+ return renderToStaticMarkup(
+
+
+ ,
+ );
+}
+
+beforeEach(() => {
+ queryState = { data: undefined, isLoading: false };
+ authSpy.mockReturnValue({ user: user({ nutrients: true }) });
+});
+
+describe("", () => {
+ it("renders nothing while the nutrients module is off", () => {
+ authSpy.mockReturnValue({ user: user({ nutrients: false }) });
+ expect(render()).toBe("");
+
+ authSpy.mockReturnValue({ user: user({}) });
+ expect(render()).toBe("");
+ });
+
+ it("renders the synced list with name, latest total (µg rendered), and day count", () => {
+ queryState = {
+ isLoading: false,
+ data: {
+ windowDays: 14,
+ nutrients: [
+ {
+ nutrient: "vitamin_d",
+ unit: "ug",
+ latestDay: "2026-07-13",
+ latestAmount: 22.5,
+ daysWithData: 5,
+ },
+ {
+ nutrient: "caffeine",
+ unit: "mg",
+ latestDay: "2026-07-13",
+ latestAmount: 310,
+ daysWithData: 14,
+ },
+ ],
+ },
+ };
+ const html = render();
+ expect(html).toContain('data-slot="nutrient-intake-card"');
+ expect(html).toContain("Vitamin D");
+ expect(html).toContain("Caffeine");
+ // Wire `ug` renders as µg; the day key renders verbatim.
+ expect(html).toContain("µg");
+ expect(html).toContain("2026-07-13");
+ const rows = html.match(/data-slot="nutrient-intake-row"/g) ?? [];
+ expect(rows).toHaveLength(2);
+ // Read-only surface: the only interactive element is the modules link.
+ expect(html).toContain('data-slot="nutrient-intake-modules-link"');
+ expect(html).not.toContain("
+
+ {/* v1.28 — read-only synced-nutrient list for the opt-in `nutrients`
+ module (renders nothing while the module is off). */}
+
);
}
diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts
index 4be9e7e8c..15be5091f 100644
--- a/src/lib/dashboard/__tests__/snapshot.test.ts
+++ b/src/lib/dashboard/__tests__/snapshot.test.ts
@@ -93,6 +93,7 @@ function moduleMap(
mcp: true,
inboundDocuments: true,
mentalHealth: true,
+ nutrients: true,
};
return { ...base, ...overrides };
}
diff --git a/src/lib/measurements/apple-health-mapping.ts b/src/lib/measurements/apple-health-mapping.ts
index 686e3e254..704f46367 100644
--- a/src/lib/measurements/apple-health-mapping.ts
+++ b/src/lib/measurements/apple-health-mapping.ts
@@ -973,7 +973,15 @@ export const HK_QUANTITY_TYPE_DEFERRED = new Set([
// `AppleSleepingBreathingDisturbances` (the per-night quantity) →
// BREATHING_DISTURBANCES, and `HKCategoryTypeIdentifierSleepApneaEvent`
// (the fired screening event) → BREATHING_DISTURBANCE_EVENT.
- // Nutrition — the maintainer directive, indefinite hold
+ // Nutrition — the Measurement path stays PERMANENTLY closed to
+ // Dietary types. Energy / macros (and sodium): indefinite hold, the
+ // anti-goal against diet-app territory stands. Water + caffeine (and
+ // the 24 micro Dietary vitamin/mineral identifiers, which were never
+ // listed here because no sample ever mapped) ride the dedicated
+ // day-total endpoint `POST /api/nutrients/batch` (closed catalog in
+ // `src/lib/nutrients/catalog.ts`) — never Measurements, so the two
+ // entries below stay deferred and a stray sample-grain post keeps
+ // skipping as unmappable.
"HKQuantityTypeIdentifierDietaryWater",
"HKQuantityTypeIdentifierDietaryCaffeine",
"HKQuantityTypeIdentifierDietaryEnergyConsumed",
diff --git a/src/lib/modules/__tests__/gate.test.ts b/src/lib/modules/__tests__/gate.test.ts
index 7065fa500..54a800165 100644
--- a/src/lib/modules/__tests__/gate.test.ts
+++ b/src/lib/modules/__tests__/gate.test.ts
@@ -357,14 +357,18 @@ describe("registry — opt-in marker", () => {
// are highly sensitive: `mcp` (remote external-assistant endpoint),
// `environment` (outbound weather fetch tied to a coarse location),
// `inboundDocuments` (sends an uploaded clinical document to the OCR/vision
- // provider), and `mentalHealth` (a depression / anxiety self-assessment — at
- // least as sensitive as mood, surfaced only on explicit opt-in). Every other
+ // provider), `mentalHealth` (a depression / anxiety self-assessment — at
+ // least as sensitive as mood, surfaced only on explicit opt-in), and
+ // `nutrients` (supplement-pattern intake synced from the phone's health
+ // store — the device-side HealthKit permission is not visible consent to
+ // server/AI-side storage, so ingest refuses while off). Every other
// module is the default-on disabled-allowlist.
const OPT_IN_KEYS = new Set([
"mcp",
"environment",
"inboundDocuments",
"mentalHealth",
+ "nutrients",
]);
it("marks only the egress-surface modules opt-in (every other is default-on)", () => {
diff --git a/src/lib/modules/registry.ts b/src/lib/modules/registry.ts
index 479b174d5..1d5b356b2 100644
--- a/src/lib/modules/registry.ts
+++ b/src/lib/modules/registry.ts
@@ -147,6 +147,15 @@ export const MODULE_KEYS = [
// assessment routes refuse server-side. Item answers are always encrypted and
// always excluded from the AI Coach / MCP regardless of this toggle.
"mentalHealth",
+ // v1.28 — micronutrient-intake sync (vitamins, minerals, water,
+ // caffeine as day totals from Apple Health). OPT-IN (off by default):
+ // the HealthKit read permission is consent to read on the DEVICE, not
+ // visible consent to "a server — and later an AI assistant — holds my
+ // supplement pattern"; the project already answered this for less
+ // sensitive data (`environment`, weather, is opt-in). With it off the
+ // ingest route refuses (403 `module.disabled`) and the settings card
+ // does not render. The `optIn` marker inverts the per-user default.
+ "nutrients",
] as const;
export type ModuleKey = (typeof MODULE_KEYS)[number];
@@ -323,6 +332,17 @@ export const MODULE_REGISTRY: Readonly> =
// off the AI Coach / MCP regardless.
optIn: true,
},
+ nutrients: {
+ key: "nutrients",
+ labelKey: "modules.nutrients.label",
+ descriptionKey: "modules.nutrients.description",
+ category: "device",
+ // Off by default: supplement-pattern intake synced from the phone's
+ // health store is server/AI-consent-sensitive even though the
+ // HealthKit read permission was granted on the device. Ships dark;
+ // ingest refuses while off (refuse-ingest posture, not surface-only).
+ optIn: true,
+ },
});
const MODULE_KEY_SET: ReadonlySet = new Set(MODULE_KEYS);
diff --git a/src/lib/nutrients/__tests__/catalog.test.ts b/src/lib/nutrients/__tests__/catalog.test.ts
new file mode 100644
index 000000000..afbcf99e4
--- /dev/null
+++ b/src/lib/nutrients/__tests__/catalog.test.ts
@@ -0,0 +1,153 @@
+/**
+ * v1.28 — nutrient-catalog invariants.
+ *
+ * The catalog is the closed source of truth for the intake sync: codes,
+ * HK identifiers, canonical units (the µg/mg guard), plausibility caps,
+ * and the EFSA references a later Coach block reads. These tests pin
+ * the invariants the routes and the wire contract rely on.
+ */
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+import { describe, expect, it } from "vitest";
+
+import {
+ NUTRIENT_CATALOG,
+ NUTRIENT_CODES,
+ NUTRIENT_DEFINITIONS,
+ isNutrientCode,
+} from "../catalog";
+
+describe("nutrient catalog", () => {
+ it("carries exactly the 26 maintainer-scoped codes (no sodium, no potassium, no macros)", () => {
+ expect(NUTRIENT_CODES).toHaveLength(26);
+ expect(Object.keys(NUTRIENT_CATALOG).sort()).toEqual(
+ [...NUTRIENT_CODES].sort(),
+ );
+ // The maintainer exclusions stay excluded.
+ for (const banned of [
+ "sodium",
+ "potassium",
+ "energy",
+ "carbohydrates",
+ "protein",
+ "fat",
+ "sugar",
+ "fiber",
+ ]) {
+ expect(isNutrientCode(banned), `banned code present: ${banned}`).toBe(
+ false,
+ );
+ }
+ });
+
+ it("codes and HealthKit identifiers are unique, and every HK id is a Dietary quantity type", () => {
+ const codes = new Set();
+ const hkIds = new Set();
+ for (const def of NUTRIENT_DEFINITIONS) {
+ expect(codes.has(def.code)).toBe(false);
+ expect(hkIds.has(def.hkIdentifier)).toBe(false);
+ codes.add(def.code);
+ hkIds.add(def.hkIdentifier);
+ expect(def.hkIdentifier).toMatch(
+ /^HKQuantityTypeIdentifierDietary[A-Za-z0-9]+$/,
+ );
+ }
+ });
+
+ it("every unit is canonical (mg | ug | ml) and water is the only ml type", () => {
+ for (const def of NUTRIENT_DEFINITIONS) {
+ expect(["mg", "ug", "ml"]).toContain(def.unit);
+ }
+ const mlCodes = NUTRIENT_DEFINITIONS.filter((d) => d.unit === "ml").map(
+ (d) => d.code,
+ );
+ expect(mlCodes).toEqual(["water"]);
+ // The known-µg micros stay µg — a silent switch to mg here would be
+ // the exact 1000× hazard the unit echo guards against.
+ for (const ugCode of [
+ "vitamin_a",
+ "biotin",
+ "folate",
+ "vitamin_b12",
+ "vitamin_d",
+ "vitamin_k",
+ "selenium",
+ "chromium",
+ "molybdenum",
+ "iodine",
+ ] as const) {
+ expect(NUTRIENT_CATALOG[ugCode].unit).toBe("ug");
+ }
+ expect(NUTRIENT_CATALOG.caffeine.unit).toBe("mg");
+ });
+
+ it("every code carries a finite positive plausibility cap", () => {
+ for (const def of NUTRIENT_DEFINITIONS) {
+ expect(Number.isFinite(def.plausibleDailyMax)).toBe(true);
+ expect(def.plausibleDailyMax).toBeGreaterThan(0);
+ }
+ // The design-pinned guards.
+ expect(NUTRIENT_CATALOG.caffeine.plausibleDailyMax).toBe(2000);
+ expect(NUTRIENT_CATALOG.water.plausibleDailyMax).toBe(20000);
+ });
+
+ it("every code has a reference entry carrying kind, direction, a citation, and a resolvable value", () => {
+ for (const def of NUTRIENT_DEFINITIONS) {
+ const ref = def.reference;
+ expect(ref, `missing reference: ${def.code}`).toBeDefined();
+ expect(["PRI", "AI", "safeLevel"]).toContain(ref.kind);
+ expect(["target", "upperGuidance"]).toContain(ref.direction);
+ expect(ref.source.length).toBeGreaterThan(0);
+ // Value shape: a uniform adult value XOR a full sex split — never
+ // both, never a half split (profile resolution depends on this).
+ const hasAdult = ref.adult != null;
+ const hasMale = ref.male != null;
+ const hasFemale = ref.female != null;
+ expect(hasMale, `half sex-split: ${def.code}`).toBe(hasFemale);
+ expect(hasAdult !== hasMale, `value shape: ${def.code}`).toBe(true);
+ for (const v of [ref.adult, ref.male, ref.female]) {
+ if (v != null) expect(v).toBeGreaterThan(0);
+ }
+ }
+ });
+
+ it("caffeine is the one upperGuidance reference; everything else is a target", () => {
+ for (const def of NUTRIENT_DEFINITIONS) {
+ expect(def.reference.direction).toBe(
+ def.code === "caffeine" ? "upperGuidance" : "target",
+ );
+ }
+ expect(NUTRIENT_CATALOG.caffeine.reference.kind).toBe("safeLevel");
+ expect(NUTRIENT_CATALOG.caffeine.reference.adult).toBe(400);
+ });
+
+ it("every citation names EFSA, except the documented chromium exception (EFSA sets no DRV)", () => {
+ for (const def of NUTRIENT_DEFINITIONS) {
+ if (def.code === "chromium") {
+ expect(def.reference.source).toContain("NIH");
+ expect(def.reference.source).toContain("EFSA");
+ continue;
+ }
+ expect(def.reference.source, `citation: ${def.code}`).toContain("EFSA");
+ }
+ });
+
+ it("iron carries the EFSA sex split (11 male / 16 female)", () => {
+ expect(NUTRIENT_CATALOG.iron.reference.male).toBe(11);
+ expect(NUTRIENT_CATALOG.iron.reference.female).toBe(16);
+ });
+
+ it("every labelKey follows nutrients.names. and resolves in the EN bundle", () => {
+ const en = JSON.parse(
+ readFileSync(join(__dirname, "../../../../messages/en.json"), "utf8"),
+ ) as { nutrients?: { names?: Record } };
+ for (const def of NUTRIENT_DEFINITIONS) {
+ expect(def.labelKey).toBe(`nutrients.names.${def.code}`);
+ expect(
+ en.nutrients?.names?.[def.code],
+ `EN name missing: ${def.code}`,
+ ).toBeTruthy();
+ }
+ });
+});
diff --git a/src/lib/nutrients/catalog.ts b/src/lib/nutrients/catalog.ts
new file mode 100644
index 000000000..03b5e273e
--- /dev/null
+++ b/src/lib/nutrients/catalog.ts
@@ -0,0 +1,307 @@
+/**
+ * Nutrient catalog — the closed code set for the micronutrient-intake
+ * sync (v1.28).
+ *
+ * 26 codes: the 24 HealthKit `Dietary*` vitamin/mineral quantity types
+ * plus DietaryWater and DietaryCaffeine. Energy, carbohydrates,
+ * protein, fat, sugar, fiber AND sodium/potassium are deliberately NOT
+ * here — the anti-goal against diet-app territory stands, and
+ * sodium/potassium are food-composition signals, not "substances
+ * taken" (maintainer decision, out until explicit user demand).
+ *
+ * This catalog is the single source of truth for:
+ * - the closed `nutrient` code enum on the wire and in storage;
+ * - the canonical storage unit per code, which is ALSO the pinned
+ * HealthKit query unit for iOS (mass types in mg or µg as listed,
+ * water in mL — never IU). The batch route rejects any entry whose
+ * echoed `unit` differs, because a silent µg/mg confusion is a
+ * 1000× corruption and the one-string echo is the cheapest guard;
+ * - the generous per-day plausibility cap (ingest guard against
+ * unit-confused or corrupted writers, NOT a health judgement —
+ * values above high-dose supplement reality get skipped);
+ * - the EFSA dietary reference values a later Coach block compares
+ * against. Every reference names its EFSA value kind (PRI /
+ * AI / safeLevel) and a direction: `target` for reference intakes,
+ * `upperGuidance` for caffeine's safe-level ceiling. Sex-split
+ * values resolve against the user profile at consumption time —
+ * a profile without sex omits the reference, never guesses.
+ *
+ * Reference values are the EFSA DRV Finder adult values (Dietary
+ * Reference Values for the EU population); each entry cites its source
+ * verbatim in `reference.source`. Where EFSA expresses a vitamin per
+ * energy unit (thiamin, niacin), the entry carries the value at the
+ * 10 MJ/day reference energy intake and says so in the citation.
+ * Chromium is the one exception: EFSA concluded no DRV can be set, so
+ * the entry cites the NIH ODS adequate intake instead — spelled out in
+ * its source string.
+ */
+
+export const NUTRIENT_CODES = [
+ // 13 vitamins
+ "vitamin_a",
+ "thiamin",
+ "riboflavin",
+ "niacin",
+ "pantothenic_acid",
+ "vitamin_b6",
+ "biotin",
+ "folate",
+ "vitamin_b12",
+ "vitamin_c",
+ "vitamin_d",
+ "vitamin_e",
+ "vitamin_k",
+ // 11 minerals (sodium + potassium excluded by directive)
+ "calcium",
+ "iron",
+ "magnesium",
+ "phosphorus",
+ "zinc",
+ "copper",
+ "manganese",
+ "selenium",
+ "chromium",
+ "molybdenum",
+ "iodine",
+ // taken substances with correlation value
+ "water",
+ "caffeine",
+] as const;
+
+export type NutrientCode = (typeof NUTRIENT_CODES)[number];
+
+/** Canonical storage units. `ug` is µg on the wire — ASCII on purpose. */
+export type NutrientUnit = "mg" | "ug" | "ml";
+
+export interface NutrientReference {
+ /** EFSA value type, named in Coach prose later (Slice B). */
+ kind: "PRI" | "AI" | "safeLevel";
+ /** `target` = reference intake; `upperGuidance` = do-not-exceed. */
+ direction: "target" | "upperGuidance";
+ /** Uniform adult value where the source is uniform. */
+ adult?: number;
+ /** Sex-split values where the source splits (e.g. iron 11 / 16). */
+ male?: number;
+ female?: number;
+ /** Citation, quoted verbatim wherever the reference surfaces. */
+ source: string;
+}
+
+export interface NutrientDefinition {
+ code: NutrientCode;
+ /** HealthKit quantity type identifier iOS reads this code from. */
+ hkIdentifier: string;
+ /** Canonical storage unit AND the pinned HK query unit. */
+ unit: NutrientUnit;
+ /** i18n key for the display name (settings card + exports). */
+ labelKey: string;
+ /**
+ * Generous per-day ingest cap in the canonical unit. Entries above it
+ * are skipped `value_out_of_range` — sized well above high-dose
+ * supplement reality so only unit confusion / corruption trips it.
+ */
+ plausibleDailyMax: number;
+ reference: NutrientReference;
+}
+
+function def(
+ code: NutrientCode,
+ hkSuffix: string,
+ unit: NutrientUnit,
+ plausibleDailyMax: number,
+ reference: NutrientReference,
+): NutrientDefinition {
+ return {
+ code,
+ hkIdentifier: `HKQuantityTypeIdentifierDietary${hkSuffix}`,
+ unit,
+ labelKey: `nutrients.names.${code}`,
+ plausibleDailyMax,
+ reference,
+ };
+}
+
+export const NUTRIENT_CATALOG: Readonly<
+ Record
+> = Object.freeze({
+ // ── Vitamins ─────────────────────────────────────────────────────
+ vitamin_a: def("vitamin_a", "VitaminA", "ug", 15000, {
+ kind: "PRI",
+ direction: "target",
+ male: 750,
+ female: 650,
+ source: "EFSA DRV 2015 (retinol equivalents, adults)",
+ }),
+ thiamin: def("thiamin", "Thiamin", "mg", 1000, {
+ kind: "PRI",
+ direction: "target",
+ adult: 1.0,
+ source: "EFSA DRV 2016 (PRI 0.1 mg/MJ ≈ 1.0 mg/d at 10 MJ/d)",
+ }),
+ riboflavin: def("riboflavin", "Riboflavin", "mg", 1000, {
+ kind: "PRI",
+ direction: "target",
+ adult: 1.6,
+ source: "EFSA DRV 2017 (adults)",
+ }),
+ niacin: def("niacin", "Niacin", "mg", 3000, {
+ kind: "PRI",
+ direction: "target",
+ adult: 16,
+ source: "EFSA DRV 2014 (PRI 1.6 mg NE/MJ ≈ 16 mg NE/d at 10 MJ/d)",
+ }),
+ pantothenic_acid: def("pantothenic_acid", "PantothenicAcid", "mg", 2000, {
+ kind: "AI",
+ direction: "target",
+ adult: 5,
+ source: "EFSA DRV 2014 (adults)",
+ }),
+ vitamin_b6: def("vitamin_b6", "VitaminB6", "mg", 1000, {
+ kind: "PRI",
+ direction: "target",
+ male: 1.7,
+ female: 1.6,
+ source: "EFSA DRV 2016 (adults)",
+ }),
+ biotin: def("biotin", "Biotin", "ug", 20000, {
+ kind: "AI",
+ direction: "target",
+ adult: 40,
+ source: "EFSA DRV 2014 (adults)",
+ }),
+ folate: def("folate", "Folate", "ug", 5000, {
+ kind: "PRI",
+ direction: "target",
+ adult: 330,
+ source: "EFSA DRV 2014 (dietary folate equivalents, adults)",
+ }),
+ vitamin_b12: def("vitamin_b12", "VitaminB12", "ug", 5000, {
+ kind: "AI",
+ direction: "target",
+ adult: 4,
+ source: "EFSA DRV 2015 (cobalamin, adults)",
+ }),
+ vitamin_c: def("vitamin_c", "VitaminC", "mg", 10000, {
+ kind: "PRI",
+ direction: "target",
+ male: 110,
+ female: 95,
+ source: "EFSA DRV 2013 (adults)",
+ }),
+ vitamin_d: def("vitamin_d", "VitaminD", "ug", 1000, {
+ kind: "AI",
+ direction: "target",
+ adult: 15,
+ source: "EFSA DRV 2016 (adults, assuming minimal cutaneous synthesis)",
+ }),
+ vitamin_e: def("vitamin_e", "VitaminE", "mg", 2000, {
+ kind: "AI",
+ direction: "target",
+ male: 13,
+ female: 11,
+ source: "EFSA DRV 2015 (alpha-tocopherol, adults)",
+ }),
+ vitamin_k: def("vitamin_k", "VitaminK", "ug", 10000, {
+ kind: "AI",
+ direction: "target",
+ adult: 70,
+ source: "EFSA DRV 2017 (phylloquinone, adults)",
+ }),
+ // ── Minerals ─────────────────────────────────────────────────────
+ calcium: def("calcium", "Calcium", "mg", 5000, {
+ kind: "PRI",
+ direction: "target",
+ adult: 950,
+ source: "EFSA DRV 2015 (adults ≥ 25 y)",
+ }),
+ iron: def("iron", "Iron", "mg", 500, {
+ kind: "PRI",
+ direction: "target",
+ male: 11,
+ female: 16,
+ source: "EFSA DRV 2015 (adults; 16 mg premenopausal women)",
+ }),
+ magnesium: def("magnesium", "Magnesium", "mg", 2000, {
+ kind: "AI",
+ direction: "target",
+ male: 350,
+ female: 300,
+ source: "EFSA DRV 2015 (adults)",
+ }),
+ phosphorus: def("phosphorus", "Phosphorus", "mg", 5000, {
+ kind: "AI",
+ direction: "target",
+ adult: 550,
+ source: "EFSA DRV 2015 (adults)",
+ }),
+ zinc: def("zinc", "Zinc", "mg", 200, {
+ kind: "PRI",
+ direction: "target",
+ male: 9.4,
+ female: 7.5,
+ source: "EFSA DRV 2014 (adults, at 300 mg/d phytate intake)",
+ }),
+ copper: def("copper", "Copper", "mg", 20, {
+ kind: "AI",
+ direction: "target",
+ male: 1.6,
+ female: 1.3,
+ source: "EFSA DRV 2015 (adults)",
+ }),
+ manganese: def("manganese", "Manganese", "mg", 50, {
+ kind: "AI",
+ direction: "target",
+ adult: 3,
+ source: "EFSA DRV 2013 (adults)",
+ }),
+ selenium: def("selenium", "Selenium", "ug", 1000, {
+ kind: "AI",
+ direction: "target",
+ adult: 70,
+ source: "EFSA DRV 2014 (adults)",
+ }),
+ chromium: def("chromium", "Chromium", "ug", 2000, {
+ kind: "AI",
+ direction: "target",
+ male: 35,
+ female: 25,
+ source: "NIH ODS 2018 (adults 19–50 y; EFSA 2014 sets no DRV for chromium)",
+ }),
+ molybdenum: def("molybdenum", "Molybdenum", "ug", 2000, {
+ kind: "AI",
+ direction: "target",
+ adult: 65,
+ source: "EFSA DRV 2013 (adults)",
+ }),
+ iodine: def("iodine", "Iodine", "ug", 2000, {
+ kind: "AI",
+ direction: "target",
+ adult: 150,
+ source: "EFSA DRV 2014 (adults)",
+ }),
+ // ── Taken substances with correlation value ──────────────────────
+ water: def("water", "Water", "ml", 20000, {
+ kind: "AI",
+ direction: "target",
+ male: 2500,
+ female: 2000,
+ source: "EFSA DRV 2010 (total water incl. food moisture, adults)",
+ }),
+ caffeine: def("caffeine", "Caffeine", "mg", 2000, {
+ kind: "safeLevel",
+ direction: "upperGuidance",
+ adult: 400,
+ source: "EFSA 2015 scientific opinion on the safety of caffeine (adults)",
+ }),
+});
+
+/** Catalog rows in canonical display order (the tuple order above). */
+export const NUTRIENT_DEFINITIONS: ReadonlyArray =
+ NUTRIENT_CODES.map((code) => NUTRIENT_CATALOG[code]);
+
+const NUTRIENT_CODE_SET: ReadonlySet = new Set(NUTRIENT_CODES);
+
+/** True when `code` is a known catalog code. */
+export function isNutrientCode(code: string): code is NutrientCode {
+ return NUTRIENT_CODE_SET.has(code);
+}
diff --git a/src/lib/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts
index 7965d922e..91acb62b3 100644
--- a/src/lib/openapi/routes/index.ts
+++ b/src/lib/openapi/routes/index.ts
@@ -52,6 +52,7 @@ import { mentalHealthPaths } from "./mental-health";
import { medicationPaths, medicationResource } from "./medications";
import { metaPaths } from "./meta";
import { moodPaths } from "./mood";
+import { nutrientPaths } from "./nutrients";
import { onboardingPaths } from "./onboarding";
import { profilePaths } from "./profile";
import { settingsPaths } from "./settings";
@@ -94,6 +95,8 @@ export const openApiPaths: NonNullable = {
...inboundDocumentPaths,
...customMetricPaths,
...onboardingPaths,
+ // v1.28 — nutrient-intake sync (appended; spread order is load-bearing).
+ ...nutrientPaths,
};
export const openApiComponents: NonNullable = {
diff --git a/src/lib/openapi/routes/nutrients.ts b/src/lib/openapi/routes/nutrients.ts
new file mode 100644
index 000000000..1b96543ff
--- /dev/null
+++ b/src/lib/openapi/routes/nutrients.ts
@@ -0,0 +1,93 @@
+/**
+ * OpenAPI route table — nutrient intake sync (v1.28).
+ *
+ * Part of the OpenAPI route table; aggregated in `./index.ts`. Request
+ * and response bodies come from `src/lib/validations/nutrients.ts` so
+ * the wire contract stays single-source. The batch description embeds
+ * the code ↔ HealthKit-identifier ↔ unit table generated from the
+ * code-side catalog — the iOS client reads the pinned HK query unit
+ * per type from here (this is the documented home of that table).
+ */
+import type { ZodOpenApiObject } from "zod-openapi";
+
+import { NUTRIENT_DEFINITIONS } from "@/lib/nutrients/catalog";
+import {
+ nutrientBatchSchema,
+ nutrientBatchResponseSchema,
+ nutrientOverviewQuerySchema,
+ nutrientOverviewSchema,
+} from "@/lib/validations/nutrients";
+import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
+
+const moduleDisabled = {
+ "403": {
+ description:
+ "The opt-in `nutrients` module is off for this account (errorCode `module.disabled`). Stop syncing and do not retry; offer the enable-in-settings hint. Only run the HealthKit read-authorization flow while the module is on.",
+ content: { "application/json": { schema: errorEnvelope } },
+ },
+} as const;
+
+/**
+ * The catalog table, rendered into the endpoint description so the
+ * OpenAPI file is the one authoritative home of the code ↔ HK ↔ unit
+ * contract (µg is `ug` on the wire; water is mL; never IU).
+ */
+const catalogTable = NUTRIENT_DEFINITIONS.map(
+ (d) => `| ${d.code} | ${d.hkIdentifier} | ${d.unit} |`,
+).join("\n");
+
+export const nutrientPaths: NonNullable = {
+ "/api/nutrients/batch": {
+ post: {
+ tags: ["Nutrients"],
+ summary: "Ingest micronutrient day totals (v1.28)",
+ description:
+ "Bulk upsert of supplement-style daily totals synced from HealthKit — vitamins, minerals, water, caffeine. Day totals only: compute HKStatisticsCollectionQuery cumulative sums day-anchored in the user's current IANA timezone; `day` is yyyy-MM-dd in that timezone (the same rule as the `stats:` day keys). Never post raw samples. Upsert key is (day, nutrient): a re-post replaces the stored total and reports `updated`. Post the current + previous local day per sync; backfill 30 days on first enable. Max 500 entries; Idempotency-Key supported; rate limit 60/min. Per-entry skips (`unit_mismatch` | `value_out_of_range` | `day_invalid`) are terminal — log and drop, do not retry the entry. Behind the opt-in `nutrients` module (403 `module.disabled` when off). Query HealthKit with exactly the listed unit per code (`ug` = microgram; do not convert to IU):\n\n| code | HealthKit identifier | unit |\n| --- | --- | --- |\n" +
+ catalogTable,
+ requestBody: {
+ required: true,
+ content: { "application/json": { schema: nutrientBatchSchema } },
+ },
+ responses: {
+ "200": {
+ description:
+ "Batch processed; per-entry statuses inserted | updated | skipped.",
+ content: {
+ "application/json": {
+ schema: dataEnvelope(
+ nutrientBatchResponseSchema,
+ "NutrientBatchEnvelope",
+ ),
+ },
+ },
+ },
+ ...moduleDisabled,
+ ...stdResponses,
+ },
+ },
+ },
+ "/api/nutrients": {
+ get: {
+ tags: ["Nutrients"],
+ summary: "Synced-nutrient window summary (v1.28)",
+ description:
+ "Per-nutrient summary of the last `days` days (default 14): latest synced day + total and the days-with-data count, in catalog order. Read-only; feeds the settings card. Behind the opt-in `nutrients` module.",
+ requestParams: { query: nutrientOverviewQuerySchema },
+ responses: {
+ "200": {
+ description: "The window summary.",
+ content: {
+ "application/json": {
+ schema: dataEnvelope(
+ nutrientOverviewSchema,
+ "NutrientOverviewEnvelope",
+ ),
+ },
+ },
+ },
+ ...moduleDisabled,
+ ...stdResponses,
+ },
+ },
+ },
+};
diff --git a/src/lib/query-keys/index.ts b/src/lib/query-keys/index.ts
index aff4cfbd7..1aa7b4199 100644
--- a/src/lib/query-keys/index.ts
+++ b/src/lib/query-keys/index.ts
@@ -33,6 +33,7 @@ import { measurementReminderKeys } from "./measurement-reminders";
import { medicationKeys } from "./medications";
import { mentalHealthKeys } from "./mental-health";
import { moodKeys } from "./mood";
+import { nutrientKeys } from "./nutrients";
import { settingsKeys } from "./settings";
import { workoutKeys } from "./workouts";
@@ -58,6 +59,7 @@ export const queryKeys = {
...environmentKeys,
...documentKeys,
...customMetrics,
+ ...nutrientKeys,
};
/**
diff --git a/src/lib/query-keys/nutrients.ts b/src/lib/query-keys/nutrients.ts
new file mode 100644
index 000000000..62d512401
--- /dev/null
+++ b/src/lib/query-keys/nutrients.ts
@@ -0,0 +1,9 @@
+/**
+ * Query keys — nutrient-intake sync (v1.28). One read: the window
+ * summary behind the read-only Settings → Sources card. Part of the
+ * centralized factory; aggregated in `./index.ts`.
+ */
+export const nutrientKeys = {
+ /** Per-nutrient window summary from `GET /api/nutrients?days=N`. */
+ nutrientIntake: (days: number) => ["nutrients", "intake", days] as const,
+};
diff --git a/src/lib/validations/nutrients.ts b/src/lib/validations/nutrients.ts
new file mode 100644
index 000000000..ce7c1dc4b
--- /dev/null
+++ b/src/lib/validations/nutrients.ts
@@ -0,0 +1,108 @@
+/**
+ * Zod schemas — nutrient intake sync (v1.28).
+ *
+ * Shared between the runtime request parsing
+ * (`src/app/api/nutrients/**`) and the OpenAPI route table
+ * (`src/lib/openapi/routes/nutrients.ts`) so the wire contract stays
+ * single-source. The closed `nutrient` code set comes from the
+ * code-side catalog (`src/lib/nutrients/catalog.ts`).
+ */
+import { z } from "zod/v4";
+
+import { NUTRIENT_CODES } from "@/lib/nutrients/catalog";
+
+export const MAX_NUTRIENT_ENTRIES_PER_BATCH = 500;
+
+export const nutrientCodeEnum = z.enum(NUTRIENT_CODES).meta({
+ id: "NutrientCode",
+ description:
+ "Closed nutrient catalog code — 24 HealthKit Dietary vitamin/mineral types plus water and caffeine. Energy, macros and sodium/potassium are out of scope.",
+});
+
+/**
+ * One day-total entry. `day` is YYYY-MM-DD in the user's IANA timezone,
+ * computed client-side (the `stats:` day-key contract); the server
+ * trusts the string after regex + calendar sanity, no re-derivation.
+ * `unit` deliberately rides the wire even though the catalog pins it:
+ * a µg/mg confusion is a silent 1000× corruption and the one-string
+ * echo is the cheapest guard — a mismatch skips the entry.
+ */
+export const nutrientEntrySchema = z
+ .object({
+ day: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
+ nutrient: nutrientCodeEnum,
+ unit: z.string().min(1).max(10),
+ amount: z.number().finite().min(0),
+ externalSourceVersion: z.string().min(1).max(120).optional(),
+ })
+ .meta({
+ id: "NutrientIntakeEntry",
+ description:
+ "One nutrient day total. day = YYYY-MM-DD in the user's IANA timezone; unit must equal the catalog's canonical unit for the code (mg | ug | ml) or the entry is skipped unit_mismatch.",
+ });
+
+export const nutrientBatchSchema = z
+ .object({
+ entries: z
+ .array(nutrientEntrySchema)
+ .min(1)
+ .max(MAX_NUTRIENT_ENTRIES_PER_BATCH),
+ })
+ .meta({
+ id: "NutrientBatchRequest",
+ description:
+ "Bulk day-total upsert, max 500 entries. Upsert key is (day, nutrient); a re-post replaces the stored total (last-writer-wins day-total contract).",
+ });
+
+/** Per-entry outcome — a re-post is by definition an update, never a duplicate. */
+export const nutrientEntryResultSchema = z
+ .object({
+ index: z.number().int().min(0),
+ status: z.enum(["inserted", "updated", "skipped"]),
+ reason: z.string().optional(),
+ })
+ .meta({
+ id: "NutrientEntryResult",
+ description:
+ "Per-entry ingest outcome. skipped reasons: unit_mismatch | value_out_of_range | day_invalid | upsert_failed. Log and drop a skipped entry — do not retry it.",
+ });
+
+export const nutrientBatchResponseSchema = z
+ .object({
+ processed: z.number().int().min(0),
+ inserted: z.number().int().min(0),
+ updated: z.number().int().min(0),
+ skipped: z.array(
+ z.object({ index: z.number().int().min(0), reason: z.string() }),
+ ),
+ entries: z.array(nutrientEntryResultSchema),
+ })
+ .meta({
+ id: "NutrientBatchResponse",
+ description:
+ "Always 200 when the envelope parses; per-entry failures surface as skipped entries, never a batch failure.",
+ });
+
+/** `GET /api/nutrients` query — window size in days, default 14. */
+export const nutrientOverviewQuerySchema = z.object({
+ days: z.coerce.number().int().min(1).max(365).default(14),
+});
+
+export const nutrientOverviewSchema = z
+ .object({
+ windowDays: z.number().int().min(1),
+ nutrients: z.array(
+ z.object({
+ nutrient: nutrientCodeEnum,
+ unit: z.string(),
+ latestDay: z.string(),
+ latestAmount: z.number(),
+ daysWithData: z.number().int().min(1),
+ }),
+ ),
+ })
+ .meta({
+ id: "NutrientIntakeOverview",
+ description:
+ "Per-nutrient window summary in catalog order: latest synced day + total, and the count of days carrying data inside the window. Nutrients without data in the window are omitted.",
+ });
diff --git a/tests/integration/doctor-report-sections.test.ts b/tests/integration/doctor-report-sections.test.ts
index c3999f6ae..629cfbafa 100644
--- a/tests/integration/doctor-report-sections.test.ts
+++ b/tests/integration/doctor-report-sections.test.ts
@@ -321,6 +321,7 @@ describe("doctor-report — module enable/disable gating", () => {
mcp: true,
inboundDocuments: true,
mentalHealth: true,
+ nutrients: true,
} as const;
it("includes every module's data when all modules are enabled", async () => {
diff --git a/tests/integration/nutrients-batch.test.ts b/tests/integration/nutrients-batch.test.ts
new file mode 100644
index 000000000..49529bc8d
--- /dev/null
+++ b/tests/integration/nutrients-batch.test.ts
@@ -0,0 +1,261 @@
+/**
+ * v1.28 — `POST /api/nutrients/batch` + `GET /api/nutrients` real-Postgres
+ * integration.
+ *
+ * Asserts the composite-PK contract the unit mocks can't pin:
+ * - a clean batch inserts one row per (user, day, nutrient)
+ * - a re-post replaces the day total in place (`updated`, no new row)
+ * - a unit-mismatched entry is skipped and never lands
+ * - the opt-in module gate refuses ingest AND read with 403
+ * `module.disabled` for an account that never opted in
+ * - the read endpoint folds stored rows into the window summary
+ */
+import { NextRequest } from "next/server";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { cookieJar } from "./mock-next-headers";
+import { getPrismaClient, truncateAllTables } from "./setup";
+
+const OPTED_IN_USER = "user-nutrients";
+const OPTED_OUT_USER = "user-nutrients-off";
+
+vi.mock("next/headers", async () => {
+ const { cookieJar, headerJar } = await import("./mock-next-headers");
+ return {
+ headers: vi.fn(async () => ({
+ get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
+ })),
+ cookies: vi.fn(async () => ({
+ get: (name: string) => {
+ const value = cookieJar.get(name);
+ return value ? { name, value } : undefined;
+ },
+ set: (name: string, value: string) => {
+ cookieJar.set(name, value);
+ },
+ delete: (name: string) => {
+ cookieJar.delete(name);
+ },
+ })),
+ };
+});
+
+vi.mock("@/lib/db-compat", () => ({
+ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
+}));
+
+async function signIn(userId: string): Promise {
+ const session = await getPrismaClient().session.create({
+ data: {
+ userId,
+ expiresAt: new Date(Date.now() + 60 * 60 * 1000),
+ },
+ });
+ cookieJar.set("healthlog_session", session.id);
+}
+
+beforeEach(async () => {
+ await truncateAllTables(getPrismaClient());
+ cookieJar.clear();
+ await getPrismaClient().user.create({
+ data: {
+ id: OPTED_IN_USER,
+ username: "nutrients-on",
+ email: "nutrients-on@example.test",
+ timezone: "Europe/Berlin",
+ // Opt-in module: only an explicit `true` enables it.
+ modulePreferencesJson: { nutrients: true },
+ },
+ });
+ await getPrismaClient().user.create({
+ data: {
+ id: OPTED_OUT_USER,
+ username: "nutrients-off",
+ email: "nutrients-off@example.test",
+ timezone: "Europe/Berlin",
+ },
+ });
+ await signIn(OPTED_IN_USER);
+});
+
+function postReq(body: unknown): NextRequest {
+ return new NextRequest("http://localhost/api/nutrients/batch", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ });
+}
+
+function recentDay(offsetDays: number): string {
+ return new Date(Date.now() - offsetDays * 24 * 60 * 60 * 1000)
+ .toISOString()
+ .slice(0, 10);
+}
+
+interface BatchEnvelope {
+ data: {
+ processed: number;
+ inserted: number;
+ updated: number;
+ skipped: Array<{ index: number; reason: string }>;
+ entries: Array<{ index: number; status: string }>;
+ };
+}
+
+describe("POST /api/nutrients/batch (real Postgres)", () => {
+ it("inserts a clean batch — one row per (user, day, nutrient)", async () => {
+ const { POST } = await import("@/app/api/nutrients/batch/route");
+ const res = await POST(
+ postReq({
+ entries: [
+ {
+ day: recentDay(1),
+ nutrient: "vitamin_d",
+ unit: "ug",
+ amount: 20,
+ externalSourceVersion: "writer-1.0",
+ },
+ { day: recentDay(1), nutrient: "caffeine", unit: "mg", amount: 250 },
+ { day: recentDay(2), nutrient: "caffeine", unit: "mg", amount: 400 },
+ ],
+ }),
+ );
+
+ expect(res.status).toBe(200);
+ const json = (await res.json()) as BatchEnvelope;
+ expect(json.data.processed).toBe(3);
+ expect(json.data.inserted).toBe(3);
+ expect(json.data.updated).toBe(0);
+
+ const stored = await getPrismaClient().nutrientIntakeDay.findMany({
+ where: { userId: OPTED_IN_USER },
+ orderBy: [{ nutrient: "asc" }, { day: "asc" }],
+ });
+ expect(stored).toHaveLength(3);
+ expect(stored.map((r) => r.nutrient)).toEqual([
+ "caffeine",
+ "caffeine",
+ "vitamin_d",
+ ]);
+ expect(stored[2].unit).toBe("ug");
+ expect(stored[2].source).toBe("APPLE_HEALTH");
+ expect(stored[2].externalSourceVersion).toBe("writer-1.0");
+ });
+
+ it("re-post replaces the day total in place (composite-PK upsert, status updated)", async () => {
+ const { POST } = await import("@/app/api/nutrients/batch/route");
+ const day = recentDay(1);
+
+ await POST(
+ postReq({
+ entries: [{ day, nutrient: "water", unit: "ml", amount: 900 }],
+ }),
+ );
+ // The steps pattern: the total grows as the day progresses.
+ const res = await POST(
+ postReq({
+ entries: [{ day, nutrient: "water", unit: "ml", amount: 1800 }],
+ }),
+ );
+
+ const json = (await res.json()) as BatchEnvelope;
+ expect(json.data.inserted).toBe(0);
+ expect(json.data.updated).toBe(1);
+ expect(json.data.entries[0].status).toBe("updated");
+
+ const stored = await getPrismaClient().nutrientIntakeDay.findMany({
+ where: { userId: OPTED_IN_USER, nutrient: "water" },
+ });
+ expect(stored).toHaveLength(1);
+ expect(stored[0].amount).toBe(1800);
+ });
+
+ it("skips a unit-mismatched entry without landing a row", async () => {
+ const { POST } = await import("@/app/api/nutrients/batch/route");
+ const res = await POST(
+ postReq({
+ entries: [
+ { day: recentDay(1), nutrient: "vitamin_d", unit: "mg", amount: 20 },
+ ],
+ }),
+ );
+ const json = (await res.json()) as BatchEnvelope;
+ expect(json.data.skipped).toEqual([{ index: 0, reason: "unit_mismatch" }]);
+ const count = await getPrismaClient().nutrientIntakeDay.count({
+ where: { userId: OPTED_IN_USER },
+ });
+ expect(count).toBe(0);
+ });
+
+ it("refuses ingest 403 module.disabled for an account that never opted in", async () => {
+ const { POST } = await import("@/app/api/nutrients/batch/route");
+ cookieJar.clear();
+ await signIn(OPTED_OUT_USER);
+
+ const res = await POST(
+ postReq({
+ entries: [
+ { day: recentDay(1), nutrient: "iron", unit: "mg", amount: 12 },
+ ],
+ }),
+ );
+ expect(res.status).toBe(403);
+ const body = (await res.json()) as { meta?: { errorCode?: string } };
+ expect(body.meta?.errorCode).toBe("module.disabled");
+ const count = await getPrismaClient().nutrientIntakeDay.count({
+ where: { userId: OPTED_OUT_USER },
+ });
+ expect(count).toBe(0);
+ });
+});
+
+describe("GET /api/nutrients (real Postgres)", () => {
+ it("folds stored rows into the catalog-ordered window summary", async () => {
+ const { POST } = await import("@/app/api/nutrients/batch/route");
+ const { GET } = await import("@/app/api/nutrients/route");
+
+ await POST(
+ postReq({
+ entries: [
+ { day: recentDay(2), nutrient: "caffeine", unit: "mg", amount: 400 },
+ { day: recentDay(1), nutrient: "caffeine", unit: "mg", amount: 250 },
+ { day: recentDay(1), nutrient: "vitamin_d", unit: "ug", amount: 20 },
+ ],
+ }),
+ );
+
+ const res = await GET(
+ new NextRequest("http://localhost/api/nutrients?days=14"),
+ );
+ expect(res.status).toBe(200);
+ const json = (await res.json()) as {
+ data: {
+ windowDays: number;
+ nutrients: Array<{
+ nutrient: string;
+ latestDay: string;
+ latestAmount: number;
+ daysWithData: number;
+ }>;
+ };
+ };
+ expect(json.data.windowDays).toBe(14);
+ expect(json.data.nutrients).toHaveLength(2);
+ // Catalog order: vitamin_d before the caffeine tail entry.
+ expect(json.data.nutrients[0].nutrient).toBe("vitamin_d");
+ expect(json.data.nutrients[1]).toMatchObject({
+ nutrient: "caffeine",
+ latestDay: recentDay(1),
+ latestAmount: 250,
+ daysWithData: 2,
+ });
+ });
+
+ it("refuses the read 403 module.disabled when the module is off", async () => {
+ const { GET } = await import("@/app/api/nutrients/route");
+ cookieJar.clear();
+ await signIn(OPTED_OUT_USER);
+ const res = await GET(new NextRequest("http://localhost/api/nutrients"));
+ expect(res.status).toBe(403);
+ });
+});