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
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);
+ });
+});