Input:
*** Begin Patch
*** Update File: scripts/health/assets/health-assets/dashboard-v5.js
@@
-  function sparkline(metricId, label) {
+  function sparkline(metricId, label) {
     const rows = metricSeries(metricId).filter((row) => row.value !== null && row.value !== undefined).slice(-45);
     if (!rows.length) return node("div", "v5-empty", "Keine Trendpunkte im Zeitraum");
-    const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
-    svg.setAttribute("class", "metric-sparkline");
-    svg.setAttribute("viewBox", "0 0 100 32");
-    svg.setAttribute("role", "img");
-    svg.setAttribute("aria-label", `${label}: ${rows.length} dokumentierte Trendpunkte; Lücken werden nicht verbunden.`);
-    const values = rows.map((row) => Number(row.value));
-    const minimum = Math.min(...values);
-    const maximum = Math.max(...values);
-    const range = maximum - minimum || 1;
-    const segments = [];
-    let current = [];
-    rows.forEach((row, index) => {
-      if (index && daysBetween(rows[index - 1].date, row.date) !== 1) {
-        if (current.length) segments.push(current);
-        current = [];
-      }
-      const x = rows.length === 1 ? 50 : (index / (rows.length - 1)) * 96 + 2;
-      const y = 29 - ((Number(row.value) - minimum) / range) * 26;
-      current.push(`${x.toFixed(2)},${y.toFixed(2)}`);
-    });
-    if (current.length) segments.push(current);
-    segments.forEach((points) => {
-      const line = document.createElementNS("http://www.w3.org/2000/svg", "polyline");
-      line.setAttribute("class", "sparkline-segment");
-      line.setAttribute("points", points.join(" "));
-      svg.append(line);
-    });
-    const dot = document.createElementNS("http://www.w3.org/2000/svg", "circle");
-    const finalPoint = segments[segments.length - 1].at(-1).split(",");
-    dot.setAttribute("class", "sparkline-dot");
-    dot.setAttribute("cx", finalPoint[0]);
-    dot.setAttribute("cy", finalPoint[1]);
-    dot.setAttribute("r", "2.2");
-    svg.append(dot);
-    return svg;
+    const chart = node("div", "metric-sparkline");
+    chart.setAttribute("role", "img");
+    chart.setAttribute("aria-label", `${label}: ${rows.length} dokumentierte Trendpunkte; Lücken werden nicht verbunden.`);
+    chart.dataset.sparklineMetric = metricId;
+    return chart;
+  }
+
+  function renderSparkline(element) {
+    if (!window.HealthV5ECharts?.hasECharts?.()) return;
+    const metricId = element.dataset.sparklineMetric;
+    const metric = (bundle.metrics || []).find((item) => item.id === metricId);
+    const rows = metricSeries(metricId).filter((row) => row.value !== null && row.value !== undefined).slice(-45);
+    if (!metric || !rows.length) return;
+    const days = rows.map((row) => row.date);
+    window.HealthV5ECharts.renderLine(element, {
+      metrics: [metric],
+      rowsByMetric: new Map([[metric.id, rows]]),
+      days,
+      source: "metric_card",
+      aria: `${metric.label}: kompakter dokumentierter Verlauf; fehlende Tage bleiben Lücken.`,
+    });
+    const instance = window.echarts?.getInstanceByDom?.(element);
+    instance?.setOption({
+      tooltip: { show: false },
+      legend: { show: false },
+      grid: { left: 4, right: 4, top: 4, bottom: 4 },
+      xAxis: { show: false },
+      yAxis: { show: false, scale: true },
+      series: [{ symbolSize: 0, lineStyle: { width: 2 } }],
+    });
   }
@@
       const footer = node("div", "metric-card-footer");
       footer.append(node("span", "v5-badge", point?.quality === "complete" ? "vollständig" : point ? "dokumentiert" : "unbekannt"));
       footer.append(node("span", "v5-meta", coverage.expected ? `${coverage.observed}/${coverage.expected} Tage im Zeitraum` : `${coverage.observed} dokumentierte Tage`));
       card.append(footer);
+      card.tabIndex = 0;
+      card.setAttribute("role", "button");
+      card.setAttribute("aria-label", `${metric.label} im Explorer öffnen`);
+      card.addEventListener("click", () => openMetricInExplorer(metric.id));
+      card.addEventListener("keydown", (event) => {
+        if (event.key === "Enter" || event.key === " ") {
+          event.preventDefault();
+          openMetricInExplorer(metric.id);
+        }
+      });
       target.append(card);
+      const spark = card.querySelector("[data-sparkline-metric]");
+      if (spark) renderSparkline(spark);
     });
     target.dataset.renderedPeriod = activePeriod;
   }
+
+  function openMetricInExplorer(metricId) {
+    selectView("explorer");
+    window.healthExplorerAddMetric?.(metricId);
+  }
@@
   function destroyChart(canvas) {
-    const existing = charts.get(canvas);
+    if (window.HealthV5ECharts?.dispose) {
+      window.HealthV5ECharts.dispose(canvas);
+    }
+    const existing = charts.get(canvas);
@@
-  function setChartEmpty(canvasId, isEmpty) {
-    const canvas = document.getElementById(canvasId);
+  function setChartEmpty(canvasId, isEmpty) {
+    const canvas = document.getElementById(canvasId);
@@
-  function chartFor(canvasId, metricIds, mode = "raw", includeBaseline = false) {
-    const canvas = document.getElementById(canvasId);
-    if (!canvas || !window.Chart) return;
+  function chartFor(canvasId, metricIds, mode = "raw", includeBaseline = false) {
+    const canvas = document.getElementById(canvasId);
+    if (!canvas) return;
@@
-    if (!days.length || !hasValue) {
+    if (!days.length || !hasValue) {
       destroyChart(canvas);
@@
       return;
     }
@@
-    const previous = charts.get(canvas);
+    const table = document.getElementById(`${canvasId}-table`);
+    if (window.HealthV5ECharts?.hasECharts?.()) {
+      const chartMetrics = metricIds.map((id) => metrics.get(id)).filter(Boolean);
+      window.HealthV5ECharts.renderLine(canvas, {
+        metrics: chartMetrics,
+        rowsByMetric,
+        days,
+        mode,
+        includeBaseline,
+        source: canvasId,
+        aria: canvas.getAttribute("aria-label") || "Dokumentierte Werte; fehlende Tage bleiben Lücken.",
+      });
+      window.HealthV5ECharts.renderTable(table, { metrics: chartMetrics, rowsByMetric, days, mode });
+      return;
+    }
+    if (!window.Chart || canvas.tagName !== "CANVAS") return;
+    const previous = charts.get(canvas);
*** End Patch


Success. Updated the following files:
M scripts/health/assets/health-assets/dashboard-v5.js

@@ -243,37 +243,32 @@
     if (!rows.length) return node("div", "v5-empty", "Keine Trendpunkte im Zeitraum");
-    const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
-    svg.setAttribute("class", "metric-sparkline");
-    svg.setAttribute("viewBox", "0 0 100 32");
-    svg.setAttribute("role", "img");
-    svg.setAttribute("aria-label", `${label}: ${rows.length} dokumentierte Trendpunkte; Lücken werden nicht verbunden.`);
-    const values = rows.map((row) => Number(row.value));
-    const minimum = Math.min(...values);
-    const maximum = Math.max(...values);
-    const range = maximum - minimum || 1;
-    const segments = [];
-    let current = [];
-    rows.forEach((row, index) => {
-      if (index && daysBetween(rows[index - 1].date, row.date) !== 1) {
-        if (current.length) segments.push(current);
-        current = [];
-      }
-      const x = rows.length === 1 ? 50 : (index / (rows.length - 1)) * 96 + 2;
-      const y = 29 - ((Number(row.value) - minimum) / range) * 26;
-      current.push(`${x.toFixed(2)},${y.toFixed(2)}`);
+    const chart = node("div", "metric-sparkline");
+    chart.setAttribute("role", "img");
+    chart.setAttribute("aria-label", `${label}: ${rows.length} dokumentierte Trendpunkte; Lücken werden nicht verbunden.`);
+    chart.dataset.sparklineMetric = metricId;
+    return chart;
+  }
+
+  function renderSparkline(element) {
+    if (!window.HealthV5ECharts?.hasECharts?.()) return;
+    const metricId = element.dataset.sparklineMetric;
+    const metric = (bundle.metrics || []).find((item) => item.id === metricId);
+    const rows = metricSeries(metricId).filter((row) => row.value !== null && row.value !== undefined).slice(-45);
+    if (!metric || !rows.length) return;
+    const days = rows.map((row) => row.date);
+    window.HealthV5ECharts.renderLine(element, {
+      metrics: [metric],
+      rowsByMetric: new Map([[metric.id, rows]]),
+      days,
+      source: "metric_card",
+      aria: `${metric.label}: kompakter dokumentierter Verlauf; fehlende Tage bleiben Lücken.`,
     });
-    if (current.length) segments.push(current);
-    segments.forEach((points) => {
-      const line = document.createElementNS("http://www.w3.org/2000/svg", "polyline");
-      line.setAttribute("class", "sparkline-segment");
-      line.setAttribute("points", points.join(" "));
-      svg.append(line);
+    const instance = window.echarts?.getInstanceByDom?.(element);
+    instance?.setOption({
+      tooltip: { show: false },
+      legend: { show: false },
+      grid: { left: 4, right: 4, top: 4, bottom: 4 },
+      xAxis: { show: false },
+      yAxis: { show: false, scale: true },
+      series: [{ symbolSize: 0, lineStyle: { width: 2 } }],
     });
-    const dot = document.createElementNS("http://www.w3.org/2000/svg", "circle");
-    const finalPoint = segments[segments.length - 1].at(-1).split(",");
-    dot.setAttribute("class", "sparkline-dot");
-    dot.setAttribute("cx", finalPoint[0]);
-    dot.setAttribute("cy", finalPoint[1]);
-    dot.setAttribute("r", "2.2");
-    svg.append(dot);
-    return svg;
   }
@@ -305,3 +300,15 @@
       card.append(footer);
+      card.tabIndex = 0;
+      card.setAttribute("role", "button");
+      card.setAttribute("aria-label", `${metric.label} im Explorer öffnen`);
+      card.addEventListener("click", () => openMetricInExplorer(metric.id));
+      card.addEventListener("keydown", (event) => {
+        if (event.key === "Enter" || event.key === " ") {
+          event.preventDefault();
+          openMetricInExplorer(metric.id);
+        }
+      });
       target.append(card);
+      const spark = card.querySelector("[data-sparkline-metric]");
+      if (spark) renderSparkline(spark);
     });
@@ -310,2 +317,7 @@

+  function openMetricInExplorer(metricId) {
+    selectView("explorer");
+    window.healthExplorerAddMetric?.(metricId);
+  }
+
   function renderDataRecency() {
@@ -509,2 +521,5 @@
   function destroyChart(canvas) {
+    if (window.HealthV5ECharts?.dispose) {
+      window.HealthV5ECharts.dispose(canvas);
+    }
     const existing = charts.get(canvas);
@@ -556,3 +571,3 @@
     const canvas = document.getElementById(canvasId);
-    if (!canvas || !window.Chart) return;
+    if (!canvas) return;
     const metrics = new Map((bundle.metrics || []).map((metric) => [metric.id, metric]));
@@ -613,2 +628,18 @@
     }
+    const table = document.getElementById(`${canvasId}-table`);
+    if (window.HealthV5ECharts?.hasECharts?.()) {
+      const chartMetrics = metricIds.map((id) => metrics.get(id)).filter(Boolean);
+      window.HealthV5ECharts.renderLine(canvas, {
+        metrics: chartMetrics,
+        rowsByMetric,
+        days,
+        mode,
+        includeBaseline,
+        source: canvasId,
+        aria: canvas.getAttribute("aria-label") || "Dokumentierte Werte; fehlende Tage bleiben Lücken.",
+      });
+      window.HealthV5ECharts.renderTable(table, { metrics: chartMetrics, rowsByMetric, days, mode });
+      return;
+    }
+    if (!window.Chart || canvas.tagName !== "CANVAS") return;
     const previous = charts.get(canvas);


Output:
Exit code: 0
Wall time: 0.1 seconds
Output:
Success. Updated the following files:
M scripts/health/assets/health-assets/dashboard-v5.js


The card interactions now use real buttons semantics and route to Explorer. I’m extending the nutrition view to fetch day details and expose validated mapping forms through the existing CSRF queue pattern.


Input:
*** Begin Patch
*** Update File: scripts/health/dashboard_v5/render.py
@@
-

Ernährung

SIGHi-Zuordnungen dokumentieren Quellen und Unsicherheit; sie sind keine allgemeine Lebensmittelsicherheitsbewertung.

Keine vollständig klassifizierten Ernährungstage im gewählten Zeitraum.
Zugängliche Datentabelle

Ernährungstag
Datum <input type='date' data-nutrition-day-date><button type='button' data-nutrition-day-open>Tag laden</button>

Histamin und Aphthen

Neutrale Hypothesenansicht: gemeinsam dokumentierte Werte werden transparent gegenübergestellt; unvollständige Eingaben ergeben keinen Index.

Keine vollständigen Datenpaare im gewählten Zeitraum.

Offene Lebensmittel-Zuordnungen

Validierte Aktionen werden lokal vorgemerkt und erst vom privaten Worker angewendet.

+

Ernährung

SIGHi-Zuordnungen dokumentieren Quellen und Unsicherheit; sie sind keine allgemeine Lebensmittelsicherheitsbewertung.

Keine vollständig klassifizierten Ernährungstage im gewählten Zeitraum.
Zugängliche Datentabelle

Ernährungstag
Datum <input type='date' data-nutrition-day-date><button type='button' data-nutrition-day-open>Tag laden</button>

Histamin und Aphthen

Neutrale Hypothesenansicht: gemeinsam dokumentierte Werte werden transparent gegenübergestellt; unvollständige Eingaben ergeben keinen Index.

Keine vollständigen Datenpaare im gewählten Zeitraum.

Offene Lebensmittel-Zuordnungen

Validierte Aktionen werden lokal vorgemerkt und erst vom privaten Worker angewendet.

*** End Patch


Success. Updated the following files:
M scripts/health/dashboard_v5/render.py

@@ -155,3 +155,3 @@
 {calendar_markup}
-

Ernährung

SIGHi-Zuordnungen dokumentieren Quellen und Unsicherheit; sie sind keine allgemeine Lebensmittelsicherheitsbewertung.

Keine vollständig klassifizierten Ernährungstage im gewählten Zeitraum.
Zugängliche Datentabelle

Ernährungstag
Datum <input type='date' data-nutrition-day-date><button type='button' data-nutrition-day-open>Tag laden</button>

Histamin und Aphthen

Neutrale Hypothesenansicht: gemeinsam dokumentierte Werte werden transparent gegenübergestellt; unvollständige Eingaben ergeben keinen Index.

Keine vollständigen Datenpaare im gewählten Zeitraum.

Offene Lebensmittel-Zuordnungen

Validierte Aktionen werden lokal vorgemerkt und erst vom privaten Worker angewendet.

+

Ernährung

SIGHi-Zuordnungen dokumentieren Quellen und Unsicherheit; sie sind keine allgemeine Lebensmittelsicherheitsbewertung.

Keine vollständig klassifizierten Ernährungstage im gewählten Zeitraum.
Zugängliche Datentabelle

Ernährungstag
Datum <input type='date' data-nutrition-day-date><button type='button' data-nutrition-day-open>Tag laden</button>

Histamin und Aphthen

Neutrale Hypothesenansicht: gemeinsam dokumentierte Werte werden transparent gegenübergestellt; unvollständige Eingaben ergeben keinen Index.

Keine vollständigen Datenpaare im gewählten Zeitraum.

Offene Lebensmittel-Zuordnungen

Validierte Aktionen werden lokal vorgemerkt und erst vom privaten Worker angewendet.

 {record_markup}


Output:
Exit code: 0
Wall time: 0.1 seconds
Output:
Success. Updated the following files:
M scripts/health/dashboard_v5/render.py


Input:
*** Begin Patch
*** Update File: scripts/health/assets/health-assets/dashboard-v5-api-explorer.js
@@
-  window.healthApiSessionReady = requestSession();
+  window.healthApiSessionReady = window.healthApiSessionReady || requestSession();
*** End Patch


Success. Updated the following files:
M scripts/health/assets/health-assets/dashboard-v5-api-explorer.js

@@ -298,3 +298,3 @@

-  window.healthApiSessionReady = requestSession();
+  window.healthApiSessionReady = window.healthApiSessionReady || requestSession();
   window.healthApiSessionReady.then(() => {


Output:
Exit code: 0
Wall time: 0.1 seconds
Output:
Success. Updated the following files:
M scripts/health/assets/health-assets/dashboard-v5-api-explorer.js


I’m sharing the browser-session bootstrap between the core V5 script and Explorer so ECharts/nutrition reads don’t race on the one-time CSRF token.


Input:
*** Begin Patch
*** Update File: scripts/health/assets/health-assets/dashboard-v5.js
@@
   const q = (selector, root = document) => root.querySelector(selector);
   const qa = (selector, root = document) => Array.from(root.querySelectorAll(selector));
+
+  function requestBrowserSession() {
+    if (window.healthApiSessionReady) return window.healthApiSessionReady;
+    const meta = document.querySelector("meta[name='health-browser-session-csrf']");
+    window.healthApiSessionReady = meta?.content ? fetch("/api/v1/browser-session", {
+      method: "POST",
+      credentials: "same-origin",
+      headers: { "X-Health-Browser-CSRF": meta.content },
+    }).then((response) => {
+      if (!response.ok) throw new Error(`session_${response.status}`);
+    }) : Promise.reject(new Error("missing_csrf"));
+    return window.healthApiSessionReady;
+  }
+
+  async function apiGet(path) {
+    await requestBrowserSession();
+    const response = await fetch(path, { credentials: "same-origin" });
+    let data = null;
+    try { data = await response.json(); } catch (_error) { data = null; }
+    if (!response.ok) throw new Error(data?.error || `api_${response.status}`);
+    return data;
+  }
@@
   function renderNutrition() {
@@
     const review = q("#mapping-review");
     if (review) {
       review.replaceChildren();
       const rows = (bundle.nutrition && bundle.nutrition.mapping_review) || [];
       if (!rows.length) review.append(node("p", "v5-empty", "Keine offenen Mapping-Prüfungen"));
       rows.forEach((item) => {
         const row = node("article", "v5-card v5-card--quiet");
         row.append(node("strong", "", item.name));
         row.append(node("div", "v5-meta", `${item.occurrences} Dokumentation(en) · Status: nicht zugeordnet`));
+        const form = node("form", "mapping-action-form");
+        form.method = "post";
+        form.action = "/health-actions/nutrition-mapping";
+        [["csrf_token", q("#view-nutrition")?.dataset.mappingCsrf || ""], ["return_to", "v5"], ["queue_key", item.queue_key || ""], ["canonical_food", item.suggested_canonical_food || ""], ["sighi_score", item.suggested_score ?? "unknown"], ["traffic_light", item.suggested_traffic_light || "unknown"], ["mapping_scope", "canonical"], ["personal_tolerance", "unknown"]].forEach(([name, value]) => {
+          const input = node("input");
+          input.type = "hidden";
+          input.name = name;
+          input.value = String(value);
+          form.append(input);
+        });
+        const submit = node("button", "", "Zuordnung vormerken");
+        submit.type = "submit";
+        submit.disabled = !item.queue_key;
+        form.append(submit);
+        row.append(form);
         review.append(row);
       });
     }
+    const dayField = q("[data-nutrition-day-date]");
+    if (dayField && !dayField.value) dayField.value = bundle.today || new Date().toISOString().slice(0, 10);
+    q("[data-nutrition-day-open]")?.addEventListener("click", loadNutritionDay, { once: true });
   }
+
+  function nutritionGroup(title, rows) {
+    const section = node("section", "nutrition-group");
+    section.append(node("h4", "", title));
+    if (!rows.length) section.append(node("p", "v5-empty", "unbekannt"));
+    rows.forEach(([label, value, unit]) => section.append(node("div", "nutrition-row", `${label}: ${value === null || value === undefined ? "unbekannt" : `${formatValue(value)} ${unit || ""}`.trim()}`)));
+    return section;
+  }
+
+  async function loadNutritionDay() {
+    const status = q("[data-nutrition-day-status]");
+    const target = q("[data-nutrition-day]");
+    const day = q("[data-nutrition-day-date]")?.value || bundle.today;
+    if (!target || !/^\d{4}-\d{2}-\d{2}$/.test(day || "")) return;
+    if (status) status.textContent = "Ernährungstag wird geladen …";
+    try {
+      const data = await apiGet(`/api/v1/nutrition/day/${encodeURIComponent(day)}`);
+      target.replaceChildren(
+        nutritionGroup("Makros", [["Energie", data.macros?.kcal, "kcal"], ["Protein", data.macros?.protein_g, "g"], ["Kohlenhydrate", data.macros?.carb_g, "g"], ["Fett", data.macros?.fat_g, "g"]]),
+        nutritionGroup("Nährstoffe", Object.entries(data.nutrients || {}).slice(0, 12).map(([key, item]) => [item.label || key, item.value, item.unit])),
+        nutritionGroup("Mahlzeiten", (data.meals || []).map((meal) => [meal.label || meal.meal, meal.item_count, "Einträge"])),
+      );
+      if (status) status.textContent = data.coverage?.status === "complete" ? "Vollständig klassifizierter Ernährungstag." : "Unvollständige Werte bleiben unbekannt.";
+    } catch (error) {
+      if (status) status.textContent = `Ernährungstag nicht verfügbar (${error.message || "api_error"}).`;
+    }
+  }
@@
-    if (activeView === "nutrition") { renderNutrition(); chartFor("nutrition-chart", ["nutrition.histamine"]); }
+    if (activeView === "nutrition") {
+      renderNutrition();
+      chartFor("nutrition-chart", ["nutrition.histamine"]);
+      chartFor("nutrition-aphthae-chart", ["nutrition.histamine", "symptom.aphthae"]);
+    }
@@
   window.healthDashboardSelectView = selectView;
*** End Patch


@@ -17,2 +17,24 @@

+  function requestBrowserSession() {
+    if (window.healthApiSessionReady) return window.healthApiSessionReady;
+    const meta = document.querySelector("meta[name='health-browser-session-csrf']");
+    window.healthApiSessionReady = meta?.content ? fetch("/api/v1/browser-session", {
+      method: "POST",
+      credentials: "same-origin",
+      headers: { "X-Health-Browser-CSRF": meta.content },
+    }).then((response) => {
+      if (!response.ok) throw new Error(`session_${response.status}`);
+    }) : Promise.reject(new Error("missing_csrf"));
+    return window.healthApiSessionReady;
+  }
+
+  async function apiGet(path) {
+    await requestBrowserSession();
+    const response = await fetch(path, { credentials: "same-origin" });
+    let data = null;
+    try { data = await response.json(); } catch (_error) { data = null; }
+    if (!response.ok) throw new Error(data?.error || `api_${response.status}`);
+    return data;
+  }
+
   function readBundle() {
@@ -406,2 +428,17 @@
         row.append(node("div", "v5-meta", `${item.occurrences} Dokumentation(en) · Status: nicht zugeordnet`));
+        const form = node("form", "mapping-action-form");
+        form.method = "post";
+        form.action = "/health-actions/nutrition-mapping";
+        [["csrf_token", q("#view-nutrition")?.dataset.mappingCsrf || ""], ["return_to", "v5"], ["queue_key", item.queue_key || ""], ["canonical_food", item.suggested_canonical_food || ""], ["sighi_score", item.suggested_score ?? "unknown"], ["traffic_light", item.suggested_traffic_light || "unknown"], ["mapping_scope", "canonical"], ["personal_tolerance", "unknown"]].forEach(([name, value]) => {
+          const input = node("input");
+          input.type = "hidden";
+          input.name = name;
+          input.value = String(value);
+          form.append(input);
+        });
+        const submit = node("button", "", "Zuordnung vormerken");
+        submit.type = "submit";
+        submit.disabled = !item.queue_key;
+        form.append(submit);
+        row.append(form);
         review.append(row);
@@ -409,2 +446,32 @@
     }
+    const dayField = q("[data-nutrition-day-date]");
+    if (dayField && !dayField.value) dayField.value = bundle.today || new Date().toISOString().slice(0, 10);
+    q("[data-nutrition-day-open]")?.addEventListener("click", loadNutritionDay, { once: true });
+  }
+
+  function nutritionGroup(title, rows) {
+    const section = node("section", "nutrition-group");
+    section.append(node("h4", "", title));
+    if (!rows.length) section.append(node("p", "v5-empty", "unbekannt"));
+    rows.forEach(([label, value, unit]) => section.append(node("div", "nutrition-row", `${label}: ${value === null || value === undefined ? "unbekannt" : `${formatValue(value)} ${unit || ""}`.trim()}`)));
+    return section;
+  }
+
+  async function loadNutritionDay() {
+    const status = q("[data-nutrition-day-status]");
+    const target = q("[data-nutrition-day]");
+    const day = q("[data-nutrition-day-date]")?.value || bundle.today;
+    if (!target || !/^\d{4}-\d{2}-\d{2}$/.test(day || "")) return;
+    if (status) status.textContent = "Ernährungstag wird geladen …";
+    try {
+      const data = await apiGet(`/api/v1/nutrition/day/${encodeURIComponent(day)}`);
+      target.replaceChildren(
+        nutritionGroup("Makros", [["Energie", data.macros?.kcal, "kcal"], ["Protein", data.macros?.protein_g, "g"], ["Kohlenhydrate", data.macros?.carb_g, "g"], ["Fett", data.macros?.fat_g, "g"]]),
+        nutritionGroup("Nährstoffe", Object.entries(data.nutrients || {}).slice(0, 12).map(([key, item]) => [item.label || key, item.value, item.unit])),
+        nutritionGroup("Mahlzeiten", (data.meals || []).map((meal) => [meal.label || meal.meal, meal.item_count, "Einträge"])),
+      );
+      if (status) status.textContent = data.coverage?.status === "complete" ? "Vollständig klassifizierter Ernährungstag." : "Unvollständige Werte bleiben unbekannt.";
+    } catch (error) {
+      if (status) status.textContent = `Ernährungstag nicht verfügbar (${error.message || "api_error"}).`;
+    }
   }
@@ -858,3 +925,7 @@
     if (activeView === "explorer") window.healthRange?.syncUrl?.("replace");
-    if (activeView === "nutrition") { renderNutrition(); chartFor("nutrition-chart", ["nutrition.histamine"]); }
+    if (activeView === "nutrition") {
+      renderNutrition();
+      chartFor("nutrition-chart", ["nutrition.histamine"]);
+      chartFor("nutrition-aphthae-chart", ["nutrition.histamine", "symptom.aphthae"]);
+    }
     if (activeView === "doctor") renderDoctor();


Output:
Exit code: 0
Wall time: 0.1 seconds
Output:
Success. Updated the following files:
M scripts/health/assets/health-assets/dashboard-v5.js


The nutrition UI now has day-detail loading, macro/nutrient/meal groups, unknown values, and neutral histamine/Aphthae charting. I’m adding the read API surfaces and strict public-shape sanitization next.


Input:
*** Begin Patch
*** Update File: scripts/health/assets/health-assets/dashboard-v5.js
@@
-    q("[data-nutrition-day-open]")?.addEventListener("click", loadNutritionDay, { once: true });
+    const openDay = q("[data-nutrition-day-open]");
+    if (openDay && openDay.dataset.bound !== "true") {
+      openDay.dataset.bound = "true";
+      openDay.addEventListener("click", loadNutritionDay);
+    }
   }
*** End Patch


@@ -448,3 +448,7 @@
     if (dayField && !dayField.value) dayField.value = bundle.today || new Date().toISOString().slice(0, 10);
-    q("[data-nutrition-day-open]")?.addEventListener("click", loadNutritionDay, { once: true });
+    const openDay = q("[data-nutrition-day-open]");
+    if (openDay && openDay.dataset.bound !== "true") {
+      openDay.dataset.bound = "true";
+      openDay.addEventListener("click", loadNutritionDay);
+    }
   }


Output:
Exit code: 0
Wall time: 0.1 seconds
Output:
Success. Updated the following files:
M scripts/health/assets/health-assets/dashboard-v5.js


Input:
*** Begin Patch
*** Update File: scripts/health/dashboard_v5/data_provider.py
@@
-        for row in connection.execute("SELECT example_name,occurrence_count FROM nutrition_review_queue WHERE status='open' ORDER BY occurrence_count DESC,last_seen DESC LIMIT 25"):
+        for row in connection.execute("SELECT normalized_name,example_name,occurrence_count,suggested_canonical_food,suggested_score FROM nutrition_review_queue WHERE status='open' ORDER BY occurrence_count DESC,last_seen DESC LIMIT 25"):
             name = public_text(row["example_name"], 160)
             if name:
-                review.append({"name": name, "occurrences": int(row["occurrence_count"] or 0), "reason": "Zuordnung fehlt"})
+                queue_key = slug(row["normalized_name"])
+                suggested = public_text(row["suggested_canonical_food"], 120, allow_empty=True)
+                score = row["suggested_score"] if row["suggested_score"] in (0, 1, 2, 3) else None
+                review.append({
+                    "queue_key": queue_key,
+                    "name": name,
+                    "occurrences": int(row["occurrence_count"] or 0),
+                    "reason": "Zuordnung fehlt",
+                    "suggested_canonical_food": suggested or "",
+                    "suggested_score": score if score is not None else "unknown",
+                    "suggested_traffic_light": "unknown",
+                })
*** End Patch


Success. Updated the following files:
M scripts/health/dashboard_v5/data_provider.py

@@ -259,6 +259,17 @@
     if table_exists(connection, "nutrition_review_queue"):
-        for row in connection.execute("SELECT example_name,occurrence_count FROM nutrition_review_queue WHERE status='open' ORDER BY occurrence_count DESC,last_seen DESC LIMIT 25"):
+        for row in connection.execute("SELECT normalized_name,example_name,occurrence_count,suggested_canonical_food,suggested_score FROM nutrition_review_queue WHERE status='open' ORDER BY occurrence_count DESC,last_seen DESC LIMIT 25"):
             name = public_text(row["example_name"], 160)
             if name:
-                review.append({"name": name, "occurrences": int(row["occurrence_count"] or 0), "reason": "Zuordnung fehlt"})
+                queue_key = slug(row["normalized_name"])
+                suggested = public_text(row["suggested_canonical_food"], 120, allow_empty=True)
+                score = row["suggested_score"] if row["suggested_score"] in (0, 1, 2, 3) else None
+                review.append({
+                    "queue_key": queue_key,
+                    "name": name,
+                    "occurrences": int(row["occurrence_count"] or 0),
+                    "reason": "Zuordnung fehlt",
+                    "suggested_canonical_food": suggested or "",
+                    "suggested_score": score if score is not None else "unknown",
+                    "suggested_traffic_light": "unknown",
+                })
     return {"daily": list(reversed(daily)), "mapping_review": review, "mapping_open_count": len(review)}


Output:
Exit code: 0
Wall time: 0.1 seconds
Output:
Success. Updated the following files:
M scripts/health/dashboard_v5/data_provider.py


Input:
*** Begin Patch
*** Update File: scripts/health/dashboard_v5/read_api.py
@@
         "/api/v1/search",
+        "/api/v1/nutrition/days",
+        "/api/v1/nutrition/mapping-queue",
     }
 )
@@
 MAX_CALENDAR_DAYS = 62
+MAX_NUTRITION_DAYS = 180
+MAX_NUTRITION_ITEMS = 200
+MAX_NUTRITION_QUEUE = 50
@@
 ALLOWED_EVENT_TYPES = frozenset(
@@
 )
+NUTRIENT_ALLOWLIST = {
+    "energy.energy": ("Energie", "kcal"),
+    "nutrient.protein": ("Protein", "g"),
+    "nutrient.carb": ("Kohlenhydrate", "g"),
+    "nutrient.fat": ("Fett", "g"),
+    "nutrient.fiber": ("Ballaststoffe", "g"),
+    "nutrient.sugar": ("Zucker", "g"),
+    "nutrient.salt": ("Salz", "g"),
+    "nutrient.sodium": ("Natrium", "mg"),
+    "nutrient.vitamin_c": ("Vitamin C", "mg"),
+    "nutrient.calcium": ("Calcium", "mg"),
+    "nutrient.iron": ("Eisen", "mg"),
+    "nutrient.magnesium": ("Magnesium", "mg"),
+}
+MEAL_LABELS = {
+    "breakfast": "Frühstück",
+    "lunch": "Mittagessen",
+    "dinner": "Abendessen",
+    "snack": "Snack",
+    "unknown": "Unbekannt",
+}
*** End Patch


Output:
apply_patch verification failed: Failed to find expected lines in /home/agent/.hermes/repos/HealthManager/scripts/health/dashboard_v5/read_api.py:
MAX_CALENDAR_DAYS = 62


One patch missed because the constant block has intervening values. I’m applying the API additions in smaller chunks against the exact file layout.


sed -n '35,95p' scripts/health/dashboard_v5/read_api.py


Output:
Chunk ID: 56c860
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 410
Output:
)
from dashboard_v5.document_originals import probe_original
from dashboard_v5.lab_registry import EXACT_LAB_NUMBER, LAB_ALLOWLIST
from dashboard_v5.metric_catalog_v2 import (
    APPLE_ANALYTICS_UNITS,
    APPLE_SOURCE_SPECS,
    BY_ID_V2,
    LAB_CATALOG_SPECS,
    MetricV2,
    SourceInventoryLimitError,
    catalog_search,
    inventory_sources,
    public_catalog,
)

TZ_NAME = "Europe/Zurich"
LOCAL_TZ = ZoneInfo(TZ_NAME)
MAX_RANGE_DAYS = 3660
MAX_SERIES_ROWS = 3660
MAX_RAW_APPLE_ROWS = 100000
MAX_RAW_LAB_ROWS = 10000
MAX_EVENT_SOURCE_ROWS = 10000
MAX_DAILY_SOURCE_ROWS = 30000
MAX_EVENT_ROWS = 1000
MAX_LAB_ROWS = 100
MAX_SEARCH_ROWS_PER_GROUP = 20
MAX_CALENDAR_DAYS = 62
MAX_FUTURE_DAYS = 366
MAX_QUERY_BYTES = 512
QUERY_TIMEOUT_SECONDS = 1.0
ALLOWED_RESOLUTIONS = frozenset({"day", "week"})
ALLOWED_EVENT_TYPES = frozenset(
    {"medication_administered", "symptom_day", "health_event", "health_period"}
)
FORBIDDEN_METADATA_TEXT = re.compile(
    r"(?:/home/|/tmp/|/Users/|/(?:var|etc|usr|opt|srv|root|run|mnt|media|private)/|"
    r"~/|file:|https?://|[A-Za-z]:[\\/]|"
    r"(?:^|[\s:|])/(?:[^/\s]+/)+[^/\s]+|\.\.[\\/]|\\\\[^\\/\s]+[\\/]|"
    r"\.hermes(?:/|$))",
    re.IGNORECASE,
)
DRIVE_ID_TOKEN = re.compile(r"(?<![A-Za-z0-9_-])[A-Za-z0-9_-]{25,}(?![A-Za-z0-9_-])")
API_PATHS = frozenset(
    {
        "/api/v1/metric-catalog",
        "/api/v1/series",
        "/api/v1/events",
        "/api/v1/labs",
        "/api/v1/calendar",
        "/api/v1/search",
    }
)
EXPECTED_ANALYTICS_UNITS = APPLE_ANALYTICS_UNITS

@dataclass
class APIError(Exception):
    status: int
    code: str

    def __str__(self) -> str:


Input:
*** Begin Patch
*** Update File: scripts/health/dashboard_v5/read_api.py
@@
 MAX_LAB_ROWS = 100
 MAX_SEARCH_ROWS_PER_GROUP = 20
 MAX_CALENDAR_DAYS = 62
+MAX_NUTRITION_DAYS = 180
+MAX_NUTRITION_ITEMS = 200
+MAX_NUTRITION_QUEUE = 50
 MAX_FUTURE_DAYS = 366
@@
 ALLOWED_EVENT_TYPES = frozenset(
     {"medication_administered", "symptom_day", "health_event", "health_period"}
 )
+NUTRIENT_ALLOWLIST = {
+    "energy.energy": ("Energie", "kcal"),
+    "nutrient.protein": ("Protein", "g"),
+    "nutrient.carb": ("Kohlenhydrate", "g"),
+    "nutrient.fat": ("Fett", "g"),
+    "nutrient.fiber": ("Ballaststoffe", "g"),
+    "nutrient.sugar": ("Zucker", "g"),
+    "nutrient.salt": ("Salz", "g"),
+    "nutrient.sodium": ("Natrium", "mg"),
+    "nutrient.vitamin_c": ("Vitamin C", "mg"),
+    "nutrient.calcium": ("Calcium", "mg"),
+    "nutrient.iron": ("Eisen", "mg"),
+    "nutrient.magnesium": ("Magnesium", "mg"),
+}
+MEAL_LABELS = {
+    "breakfast": "Frühstück",
+    "lunch": "Mittagessen",
+    "dinner": "Abendessen",
+    "snack": "Snack",
+    "unknown": "Unbekannt",
+}
@@
         "/api/v1/calendar",
         "/api/v1/search",
+        "/api/v1/nutrition/days",
+        "/api/v1/nutrition/mapping-queue",
     }
 )
*** End Patch


Success. Updated the following files:
M scripts/health/dashboard_v5/read_api.py

@@ -61,2 +61,5 @@
 MAX_CALENDAR_DAYS = 62
+MAX_NUTRITION_DAYS = 180
+MAX_NUTRITION_ITEMS = 200
+MAX_NUTRITION_QUEUE = 50
 MAX_FUTURE_DAYS = 366
@@ -68,2 +71,23 @@
 )
+NUTRIENT_ALLOWLIST = {
+    "energy.energy": ("Energie", "kcal"),
+    "nutrient.protein": ("Protein", "g"),
+    "nutrient.carb": ("Kohlenhydrate", "g"),
+    "nutrient.fat": ("Fett", "g"),
+    "nutrient.fiber": ("Ballaststoffe", "g"),
+    "nutrient.sugar": ("Zucker", "g"),
+    "nutrient.salt": ("Salz", "g"),
+    "nutrient.sodium": ("Natrium", "mg"),
+    "nutrient.vitamin_c": ("Vitamin C", "mg"),
+    "nutrient.calcium": ("Calcium", "mg"),
+    "nutrient.iron": ("Eisen", "mg"),
+    "nutrient.magnesium": ("Magnesium", "mg"),
+}
+MEAL_LABELS = {
+    "breakfast": "Frühstück",
+    "lunch": "Mittagessen",
+    "dinner": "Abendessen",
+    "snack": "Snack",
+    "unknown": "Unbekannt",
+}
 FORBIDDEN_METADATA_TEXT = re.compile(
@@ -84,2 +108,4 @@
         "/api/v1/search",
+        "/api/v1/nutrition/days",
+        "/api/v1/nutrition/mapping-queue",
     }


Output:
Exit code: 0
Wall time: 0.1 seconds
Output:
Success. Updated the following files:
M scripts/health/dashboard_v5/read_api.py


Input:
*** Begin Patch
*** Update File: scripts/health/dashboard_v5/read_api.py
@@
 def _day_nutrition(connection: sqlite3.Connection, requested: str) -> dict[str, Any]:
@@
     }
+
+
+def _nutrition_complete(row: sqlite3.Row) -> bool:
+    return (
+        int(row["item_count"] or 0) > 0
+        and int(row["histamine_unknown_count"] or 0) == 0
+        and row["histamine_score"] is not None
+        and row["histamine_label"] in {"green", "yellow", "orange", "red"}
+    )
+
+
+def _public_number(value: Any) -> int | float | None:
+    try:
+        numeric = float(value)
+    except (TypeError, ValueError):
+        return None
+    if not math.isfinite(numeric):
+        return None
+    rounded = round(numeric, 3)
+    return int(rounded) if rounded.is_integer() else rounded
+
+
+def _nutrition_days(connection: sqlite3.Connection, params: dict[str, str]) -> dict[str, Any]:
+    start, end = parse_range(params)
+    if not table_exists(connection, "nutrition_daily_summary_v2"):
+        return {"version": 1, "timezone": TZ_NAME, "definition": _histamine_definition(), "days": []}
+    sql = """SELECT datum,kcal,protein_g,carb_g,fat_g,item_count,histamine_score,
+                    histamine_unknown_count,histamine_label
+             FROM nutrition_daily_summary_v2 WHERE datum<=?"""
+    values: list[Any] = [local_today().isoformat()]
+    if start:
+        sql += " AND datum>=?"
+        values.append(start.isoformat())
+    if end:
+        sql += " AND datum<=?"
+        values.append(end.isoformat())
+    sql += " ORDER BY datum DESC LIMIT ?"
+    values.append(MAX_NUTRITION_DAYS + 1)
+    rows = list(connection.execute(sql, values))
+    if len(rows) > MAX_NUTRITION_DAYS:
+        raise APIError(422, "row_limit_exceeded")
+    days = []
+    for row in reversed(rows):
+        day = parse_day(row["datum"])
+        if not day:
+            continue
+        complete = _nutrition_complete(row)
+        days.append({
+            "date": day,
+            "macros": {
+                "kcal": _public_number(row["kcal"]),
+                "protein_g": _public_number(row["protein_g"]),
+                "carb_g": _public_number(row["carb_g"]),
+                "fat_g": _public_number(row["fat_g"]),
+            },
+            "item_count": int(row["item_count"] or 0),
+            "histamine": {
+                "score": _public_number(row["histamine_score"]) if complete else None,
+                "traffic_light": row["histamine_label"] if complete else "unknown",
+                "unknown_items": int(row["histamine_unknown_count"] or 0),
+                "status": "complete" if complete else "incomplete",
+            },
+        })
+    return {"version": 1, "timezone": TZ_NAME, "definition": _histamine_definition(), "days": days}
+
+
+def _histamine_definition() -> dict[str, Any]:
+    return {
+        "id": "sighi_mapping_load_v1",
+        "version": 1,
+        "components": ["mapped_item_scores", "unknown_item_count", "traffic_light"],
+        "coverage_semantics": "unknown_or_unmapped_items_make_day_incomplete",
+        "insufficient_inputs": "no_index",
+    }
+
+
+def _nutrition_day_detail(connection: sqlite3.Connection, requested: date) -> dict[str, Any]:
+    day = requested.isoformat()
+    if not table_exists(connection, "nutrition_daily_summary_v2"):
+        return {"version": 1, "date": day, "timezone": TZ_NAME, "status": "supported_no_data"}
+    row = connection.execute(
+        """SELECT datum,kcal,protein_g,carb_g,fat_g,item_count,nutrient_json,
+                  histamine_score,histamine_unknown_count,histamine_label
+           FROM nutrition_daily_summary_v2 WHERE datum=? LIMIT 1""",
+        (day,),
+    ).fetchone()
+    if row is None:
+        return {"version": 1, "date": day, "timezone": TZ_NAME, "status": "supported_no_data"}
+    complete = _nutrition_complete(row)
+    nutrients: dict[str, dict[str, Any]] = {}
+    try:
+        raw_nutrients = json.loads(row["nutrient_json"] or "{}")
+    except json.JSONDecodeError:
+        raw_nutrients = {}
+    if isinstance(raw_nutrients, dict):
+        for key, (label, unit) in NUTRIENT_ALLOWLIST.items():
+            nutrients[key] = {"label": label, "value": _public_number(raw_nutrients.get(key)), "unit": unit}
+    meals = []
+    if table_exists(connection, "nutrition_meal_summary"):
+        for meal in connection.execute(
+            """SELECT meal,kcal,protein_g,carb_g,fat_g,item_count,histamine_score,histamine_label
+               FROM nutrition_meal_summary WHERE datum=?
+               ORDER BY CASE meal WHEN 'breakfast' THEN 1 WHEN 'lunch' THEN 2 WHEN 'dinner' THEN 3 WHEN 'snack' THEN 4 ELSE 9 END LIMIT 12""",
+            (day,),
+        ):
+            meal_id = str(meal["meal"] or "unknown")
+            meals.append({
+                "meal": meal_id if meal_id in MEAL_LABELS else "unknown",
+                "label": MEAL_LABELS.get(meal_id, "Unbekannt"),
+                "item_count": int(meal["item_count"] or 0),
+                "macros": {
+                    "kcal": _public_number(meal["kcal"]),
+                    "protein_g": _public_number(meal["protein_g"]),
+                    "carb_g": _public_number(meal["carb_g"]),
+                    "fat_g": _public_number(meal["fat_g"]),
+                },
+                "histamine": {
+                    "score": _public_number(meal["histamine_score"]) if meal["histamine_label"] in {"green", "yellow", "orange", "red"} else None,
+                    "traffic_light": meal["histamine_label"] if meal["histamine_label"] in {"green", "yellow", "orange", "red"} else "unknown",
+                },
+            })
+    items = []
+    if table_exists(connection, "nutrition_items"):
+        for item in connection.execute(
+            """SELECT i.meal,i.name,i.brand,i.amount,i.amount_unit,i.kcal,i.protein_g,i.carb_g,i.fat_g,
+                      h.canonical_food,h.sighi_score,h.traffic_light,h.confidence
+               FROM nutrition_items i LEFT JOIN nutrition_histamine_scores h ON h.item_id=i.id
+               WHERE i.datum=? ORDER BY i.meal,i.id LIMIT ?""",
+            (day, MAX_NUTRITION_ITEMS + 1),
+        ):
+            if len(items) >= MAX_NUTRITION_ITEMS:
+                raise APIError(422, "row_limit_exceeded")
+            name = safe_metadata_text(item["name"], 120, allow_empty=False)
+            brand = safe_metadata_text(item["brand"], 80, allow_empty=True)
+            canonical = safe_metadata_text(item["canonical_food"], 120, allow_empty=True)
+            if name is None or brand is None or canonical is None:
+                continue
+            items.append({
+                "meal": item["meal"] if item["meal"] in MEAL_LABELS else "unknown",
+                "name": name,
+                "brand": brand,
+                "amount": _public_number(item["amount"]),
+                "amount_unit": item["amount_unit"] if item["amount_unit"] in {"g", "ml", "piece", "portion"} else "unknown",
+                "macros": {
+                    "kcal": _public_number(item["kcal"]),
+                    "protein_g": _public_number(item["protein_g"]),
+                    "carb_g": _public_number(item["carb_g"]),
+                    "fat_g": _public_number(item["fat_g"]),
+                },
+                "histamine": {
+                    "canonical_food": canonical or None,
+                    "score": int(item["sighi_score"]) if item["sighi_score"] in (0, 1, 2, 3) else None,
+                    "traffic_light": item["traffic_light"] if item["traffic_light"] in {"green", "yellow", "orange", "red"} else "unknown",
+                    "confidence": item["confidence"] if item["confidence"] in {"low", "medium", "high"} else "low",
+                },
+            })
+    return {
+        "version": 1,
+        "date": day,
+        "timezone": TZ_NAME,
+        "status": "documented",
+        "definition": _histamine_definition(),
+        "coverage": {
+            "status": "complete" if complete else "incomplete",
+            "item_count": int(row["item_count"] or 0),
+            "unknown_items": int(row["histamine_unknown_count"] or 0),
+            "insufficient_inputs": None if complete else "no_index",
+        },
+        "macros": {
+            "kcal": _public_number(row["kcal"]),
+            "protein_g": _public_number(row["protein_g"]),
+            "carb_g": _public_number(row["carb_g"]),
+            "fat_g": _public_number(row["fat_g"]),
+        },
+        "nutrients": nutrients,
+        "histamine": {
+            "score": _public_number(row["histamine_score"]) if complete else None,
+            "traffic_light": row["histamine_label"] if complete else "unknown",
+        },
+        "meals": meals,
+        "items": items,
+    }
+
+
+def _nutrition_mapping_queue(connection: sqlite3.Connection, params: dict[str, str]) -> dict[str, Any]:
+    if not table_exists(connection, "nutrition_review_queue"):
+        return {"version": 1, "timezone": TZ_NAME, "items": []}
+    status = params.get("status", "open")
+    if status not in {"open"}:
+        raise APIError(400, "status_not_allowed")
+    items = []
+    for row in connection.execute(
+        """SELECT normalized_name,example_name,occurrence_count,first_seen,last_seen,
+                  suggested_canonical_food,suggested_score,reason,status
+           FROM nutrition_review_queue WHERE status=?
+           ORDER BY occurrence_count DESC,last_seen DESC LIMIT ?""",
+        (status, MAX_NUTRITION_QUEUE + 1),
+    ):
+        if len(items) >= MAX_NUTRITION_QUEUE:
+            raise APIError(422, "row_limit_exceeded")
+        example = safe_metadata_text(row["example_name"], 160, allow_empty=False)
+        suggested = safe_metadata_text(row["suggested_canonical_food"], 120, allow_empty=True)
+        reason = safe_metadata_text(row["reason"], 160, allow_empty=True)
+        if example is None or suggested is None or reason is None:
+            continue
+        items.append({
+            "queue_key": re.sub(r"[^a-z0-9_]+", "_", str(row["normalized_name"] or "").casefold()).strip("_")[:80],
+            "example_name": example,
+            "occurrence_count": int(row["occurrence_count"] or 0),
+            "first_seen": parse_day(row["first_seen"]),
+            "last_seen": parse_day(row["last_seen"]),
+            "suggested_canonical_food": suggested,
+            "suggested_score": int(row["suggested_score"]) if row["suggested_score"] in (0, 1, 2, 3) else None,
+            "reason": reason,
+            "status": "open",
+            "action_contract": "nutrition_mapping_action_v1",
+        })
+    return {"version": 1, "timezone": TZ_NAME, "definition": _histamine_definition(), "items": items}
*** End Patch


@@ -1235,2 +1235,220 @@

+def _nutrition_complete(row: sqlite3.Row) -> bool:
+    return (
+        int(row["item_count"] or 0) > 0
+        and int(row["histamine_unknown_count"] or 0) == 0
+        and row["histamine_score"] is not None
+        and row["histamine_label"] in {"green", "yellow", "orange", "red"}
+    )
+
+
+def _public_number(value: Any) -> int | float | None:
+    try:
+        numeric = float(value)
+    except (TypeError, ValueError):
+        return None
+    if not math.isfinite(numeric):
+        return None
+    rounded = round(numeric, 3)
+    return int(rounded) if rounded.is_integer() else rounded
+
+
+def _nutrition_days(connection: sqlite3.Connection, params: dict[str, str]) -> dict[str, Any]:
+    start, end = parse_range(params)
+    if not table_exists(connection, "nutrition_daily_summary_v2"):
+        return {"version": 1, "timezone": TZ_NAME, "definition": _histamine_definition(), "days": []}
+    sql = """SELECT datum,kcal,protein_g,carb_g,fat_g,item_count,histamine_score,
+                    histamine_unknown_count,histamine_label
+             FROM nutrition_daily_summary_v2 WHERE datum<=?"""
+    values: list[Any] = [local_today().isoformat()]
+    if start:
+        sql += " AND datum>=?"
+        values.append(start.isoformat())
+    if end:
+        sql += " AND datum<=?"
+        values.append(end.isoformat())
+    sql += " ORDER BY datum DESC LIMIT ?"
+    values.append(MAX_NUTRITION_DAYS + 1)
+    rows = list(connection.execute(sql, values))
+    if len(rows) > MAX_NUTRITION_DAYS:
+        raise APIError(422, "row_limit_exceeded")
+    days = []
+    for row in reversed(rows):
+        day = parse_day(row["datum"])
+        if not day:
+            continue
+        complete = _nutrition_complete(row)
+        days.append({
+            "date": day,
+            "macros": {
+                "kcal": _public_number(row["kcal"]),
+                "protein_g": _public_number(row["protein_g"]),
+                "carb_g": _public_number(row["carb_g"]),
+                "fat_g": _public_number(row["fat_g"]),
+            },
+            "item_count": int(row["item_count"] or 0),
+            "histamine": {
+                "score": _public_number(row["histamine_score"]) if complete else None,
+                "traffic_light": row["histamine_label"] if complete else "unknown",
+                "unknown_items": int(row["histamine_unknown_count"] or 0),
+                "status": "complete" if complete else "incomplete",
+            },
+        })
+    return {"version": 1, "timezone": TZ_NAME, "definition": _histamine_definition(), "days": days}
+
+
+def _histamine_definition() -> dict[str, Any]:
+    return {
+        "id": "sighi_mapping_load_v1",
+        "version": 1,
+        "components": ["mapped_item_scores", "unknown_item_count", "traffic_light"],
+        "coverage_semantics": "unknown_or_unmapped_items_make_day_incomplete",
+        "insufficient_inputs": "no_index",
+    }
+
+
+def _nutrition_day_detail(connection: sqlite3.Connection, requested: date) -> dict[str, Any]:
+    day = requested.isoformat()
+    if not table_exists(connection, "nutrition_daily_summary_v2"):
+        return {"version": 1, "date": day, "timezone": TZ_NAME, "status": "supported_no_data"}
+    row = connection.execute(
+        """SELECT datum,kcal,protein_g,carb_g,fat_g,item_count,nutrient_json,
+                  histamine_score,histamine_unknown_count,histamine_label
+           FROM nutrition_daily_summary_v2 WHERE datum=? LIMIT 1""",
+        (day,),
+    ).fetchone()
+    if row is None:
+        return {"version": 1, "date": day, "timezone": TZ_NAME, "status": "supported_no_data"}
+    complete = _nutrition_complete(row)
+    nutrients: dict[str, dict[str, Any]] = {}
+    try:
+        raw_nutrients = json.loads(row["nutrient_json"] or "{}")
+    except json.JSONDecodeError:
+        raw_nutrients = {}
+    if isinstance(raw_nutrients, dict):
+        for key, (label, unit) in NUTRIENT_ALLOWLIST.items():
+            nutrients[key] = {"label": label, "value": _public_number(raw_nutrients.get(key)), "unit": unit}
+    meals = []
+    if table_exists(connection, "nutrition_meal_summary"):
+        for meal in connection.execute(
+            """SELECT meal,kcal,protein_g,carb_g,fat_g,item_count,histamine_score,histamine_label
+               FROM nutrition_meal_summary WHERE datum=?
+               ORDER BY CASE meal WHEN 'breakfast' THEN 1 WHEN 'lunch' THEN 2 WHEN 'dinner' THEN 3 WHEN 'snack' THEN 4 ELSE 9 END LIMIT 12""",
+            (day,),
+        ):
+            meal_id = str(meal["meal"] or "unknown")
+            meals.append({
+                "meal": meal_id if meal_id in MEAL_LABELS else "unknown",
+                "label": MEAL_LABELS.get(meal_id, "Unbekannt"),
+                "item_count": int(meal["item_count"] or 0),
+                "macros": {
+                    "kcal": _public_number(meal["kcal"]),
+                    "protein_g": _public_number(meal["protein_g"]),
+                    "carb_g": _public_number(meal["carb_g"]),
+                    "fat_g": _public_number(meal["fat_g"]),
+                },
+                "histamine": {
+                    "score": _public_number(meal["histamine_score"]) if meal["histamine_label"] in {"green", "yellow", "orange", "red"} else None,
+                    "traffic_light": meal["histamine_label"] if meal["histamine_label"] in {"green", "yellow", "orange", "red"} else "unknown",
+                },
+            })
+    items = []
+    if table_exists(connection, "nutrition_items"):
+        for item in connection.execute(
+            """SELECT i.meal,i.name,i.brand,i.amount,i.amount_unit,i.kcal,i.protein_g,i.carb_g,i.fat_g,
+                      h.canonical_food,h.sighi_score,h.traffic_light,h.confidence
+               FROM nutrition_items i LEFT JOIN nutrition_histamine_scores h ON h.item_id=i.id
+               WHERE i.datum=? ORDER BY i.meal,i.id LIMIT ?""",
+            (day, MAX_NUTRITION_ITEMS + 1),
+        ):
+            if len(items) >= MAX_NUTRITION_ITEMS:
+                raise APIError(422, "row_limit_exceeded")
+            name = safe_metadata_text(item["name"], 120, allow_empty=False)
+            brand = safe_metadata_text(item["brand"], 80, allow_empty=True)
+            canonical = safe_metadata_text(item["canonical_food"], 120, allow_empty=True)
+            if name is None or brand is None or canonical is None:
+                continue
+            items.append({
+                "meal": item["meal"] if item["meal"] in MEAL_LABELS else "unknown",
+                "name": name,
+                "brand": brand,
+                "amount": _public_number(item["amount"]),
+                "amount_unit": item["amount_unit"] if item["amount_unit"] in {"g", "ml", "piece", "portion"} else "unknown",
+                "macros": {
+                    "kcal": _public_number(item["kcal"]),
+                    "protein_g": _public_number(item["protein_g"]),
+                    "carb_g": _public_number(item["carb_g"]),
+                    "fat_g": _public_number(item["fat_g"]),
+                },
+                "histamine": {
+                    "canonical_food": canonical or None,
+                    "score": int(item["sighi_score"]) if item["sighi_score"] in (0, 1, 2, 3) else None,
+                    "traffic_light": item["traffic_light"] if item["traffic_light"] in {"green", "yellow", "orange", "red"} else "unknown",
+                    "confidence": item["confidence"] if item["confidence"] in {"low", "medium", "high"} else "low",
+                },
+            })
+    return {
+        "version": 1,
+        "date": day,
+        "timezone": TZ_NAME,
+        "status": "documented",
+        "definition": _histamine_definition(),
+        "coverage": {
+            "status": "complete" if complete else "incomplete",
+            "item_count": int(row["item_count"] or 0),
+            "unknown_items": int(row["histamine_unknown_count"] or 0),
+            "insufficient_inputs": None if complete else "no_index",
+        },
+        "macros": {
+            "kcal": _public_number(row["kcal"]),
+            "protein_g": _public_number(row["protein_g"]),
+            "carb_g": _public_number(row["carb_g"]),
+            "fat_g": _public_number(row["fat_g"]),
+        },
+        "nutrients": nutrients,
+        "histamine": {
+            "score": _public_number(row["histamine_score"]) if complete else None,
+            "traffic_light": row["histamine_label"] if complete else "unknown",
+        },
+        "meals": meals,
+        "items": items,
+    }
+
+
+def _nutrition_mapping_queue(connection: sqlite3.Connection, params: dict[str, str]) -> dict[str, Any]:
+    if not table_exists(connection, "nutrition_review_queue"):
+        return {"version": 1, "timezone": TZ_NAME, "items": []}
+    status = params.get("status", "open")
+    if status not in {"open"}:
+        raise APIError(400, "status_not_allowed")
+    items = []
+    for row in connection.execute(
+        """SELECT normalized_name,example_name,occurrence_count,first_seen,last_seen,
+                  suggested_canonical_food,suggested_score,reason,status
+           FROM nutrition_review_queue WHERE status=?
+           ORDER BY occurrence_count DESC,last_seen DESC LIMIT ?""",
+        (status, MAX_NUTRITION_QUEUE + 1),
+    ):
+        if len(items) >= MAX_NUTRITION_QUEUE:
+            raise APIError(422, "row_limit_exceeded")
+        example = safe_metadata_text(row["example_name"], 160, allow_empty=False)
+        suggested = safe_metadata_text(row["suggested_canonical_food"], 120, allow_empty=True)
+        reason = safe_metadata_text(row["reason"], 160, allow_empty=True)
+        if example is None or suggested is None or reason is None:
+            continue
+        items.append({
+            "queue_key": re.sub(r"[^a-z0-9_]+", "_", str(row["normalized_name"] or "").casefold()).strip("_")[:80],
+            "example_name": example,
+            "occurrence_count": int(row["occurrence_count"] or 0),
+            "first_seen": parse_day(row["first_seen"]),
+            "last_seen": parse_day(row["last_seen"]),
+            "suggested_canonical_food": suggested,
+            "suggested_score": int(row["suggested_score"]) if row["suggested_score"] in (0, 1, 2, 3) else None,
+            "reason": reason,
+            "status": "open",
+            "action_contract": "nutrition_mapping_action_v1",
+        })
+    return {"version": 1, "timezone": TZ_NAME, "definition": _histamine_definition(), "items": items}
+
+
 def _day(connection: sqlite3.Connection, day: date) -> dict[str, Any]:


Output:
Exit code: 0
Wall time: 0.1 seconds
Output:
Success. Updated the following files:
M scripts/health/dashboard_v5/read_api.py


Input:
*** Begin Patch
*** Update File: scripts/health/dashboard_v5/read_api.py
@@
         and path not in allowed_record
         and not document_route
         and not re.fullmatch(r"/api/v1/day/\d{4}-\d{2}-\d{2}", path)
+        and not re.fullmatch(r"/api/v1/nutrition/day/\d{4}-\d{2}-\d{2}", path)
     ):
@@
         if path.startswith("/api/v1/day/"):
             if query:
                 raise APIError(400, "unknown_parameter")
             requested_day = parse_iso_day(path.rsplit("/", 1)[-1])
             if requested_day > local_today() + timedelta(days=MAX_FUTURE_DAYS):
                 raise APIError(422, "future_date_not_allowed")
             return _day(connection, requested_day)
+        if path == "/api/v1/nutrition/days":
+            return _nutrition_days(connection, parse_query(query, {"from", "to"}))
+        if path.startswith("/api/v1/nutrition/day/"):
+            if query:
+                raise APIError(400, "unknown_parameter")
+            requested_day = parse_iso_day(path.rsplit("/", 1)[-1])
+            if requested_day > local_today():
+                raise APIError(422, "future_date_not_allowed")
+            return _nutrition_day_detail(connection, requested_day)
+        if path == "/api/v1/nutrition/mapping-queue":
+            return _nutrition_mapping_queue(connection, parse_query(query, {"status"}))
         if path == "/api/v1/calendar":
*** End Patch


@@ -2505,2 +2505,3 @@
         and not re.fullmatch(r"/api/v1/day/\d{4}-\d{2}-\d{2}", path)
+        and not re.fullmatch(r"/api/v1/nutrition/day/\d{4}-\d{2}-\d{2}", path)
     ):
@@ -2592,2 +2593,13 @@
             return _day(connection, requested_day)
+        if path == "/api/v1/nutrition/days":
+            return _nutrition_days(connection, parse_query(query, {"from", "to"}))
+        if path.startswith("/api/v1/nutrition/day/"):
+            if query:
+                raise APIError(400, "unknown_parameter")
+            requested_day = parse_iso_day(path.rsplit("/", 1)[-1])
+            if requested_day > local_today():
+                raise APIError(422, "future_date_not_allowed")
+            return _nutrition_day_detail(connection, requested_day)
+        if path == "/api/v1/nutrition/mapping-queue":
+            return _nutrition_mapping_queue(connection, parse_query(query, {"status"}))
         if path == "/api/v1/calendar":


Output:
Exit code: 0
Wall time: 0.1 seconds
Output:
Success. Updated the following files:
M scripts/health/dashboard_v5/read_api.py


The nutrition read API now returns only allowlisted macros, nutrients, meal labels, and public-safe item text. I’m adding the mapping action route to the local server and the worker transaction path.


Input:
*** Begin Patch
*** Update File: scripts/health/health_dashboard_server.py
@@
 CHECKIN_ROUTE = "/health-actions/symptom-checkin"
+NUTRITION_MAPPING_ROUTE = "/health-actions/nutrition-mapping"
@@
 SYMPTOM_FIELDS = ("aphthen", "gi", "fatigue", "skin", "eyes", "joints", "vascular")
+MAPPING_SCORES = {"0", "1", "2", "3", "unknown"}
+MAPPING_LIGHTS = {"green", "yellow", "orange", "red", "unknown"}
+MAPPING_SCOPES = {"canonical"}
+PERSONAL_TOLERANCE = {"unknown", "safe", "problematic", "unclear"}
@@
 def validate_symptom_submission(
@@
     return day, scores, notes, return_to
+
+
+def validate_mapping_submission(form: dict[str, list[str]]) -> tuple[dict[str, object], str]:
+    required = {
+        "csrf_token",
+        "queue_key",
+        "canonical_food",
+        "sighi_score",
+        "traffic_light",
+        "mapping_scope",
+        "personal_tolerance",
+        "return_to",
+    }
+    if frozenset(form) != frozenset(required) or any(len(values) != 1 for values in form.values()):
+        raise ValueError("invalid mapping shape")
+    return_to = form["return_to"][0]
+    if return_to != "v5":
+        raise ValueError("invalid return target")
+    queue_key = form["queue_key"][0].strip()
+    canonical_food = " ".join(form["canonical_food"][0].split())
+    score = form["sighi_score"][0]
+    traffic_light = form["traffic_light"][0]
+    scope = form["mapping_scope"][0]
+    tolerance = form["personal_tolerance"][0]
+    if not re.fullmatch(r"[a-z0-9_]{1,80}", queue_key):
+        raise ValueError("invalid queue key")
+    if len(canonical_food) > 120 or re.search(r"[\x00-\x1f\x7f]|https?://|/|\\\\|\.hermes", canonical_food, re.I):
+        raise ValueError("invalid canonical food")
+    if score not in MAPPING_SCORES or traffic_light not in MAPPING_LIGHTS or scope not in MAPPING_SCOPES or tolerance not in PERSONAL_TOLERANCE:
+        raise ValueError("invalid mapping value")
+    if score == "unknown" and traffic_light != "unknown":
+        raise ValueError("unknown score must keep unknown light")
+    if score != "unknown" and not canonical_food:
+        raise ValueError("canonical food required")
+    return {
+        "version": 1,
+        "action": "nutrition_mapping",
+        "queue_key": queue_key,
+        "canonical_food": canonical_food,
+        "sighi_score": None if score == "unknown" else int(score),
+        "traffic_light": traffic_light,
+        "mapping_scope": scope,
+        "personal_tolerance": tolerance,
+    }, return_to
@@
-def write_symptom_checkin(day: str, scores: dict[str, int], notes: str) -> None:
+def write_action_payload(payload: dict[str, object]) -> None:
     """Atomically enqueue a validated action; the network server never writes the DB."""
     with _action_lock:
         ensure_private_inbox()
@@
-        payload = json.dumps(
-            {
-                "version": 1,
-                "action": "symptom_checkin",
-                "date": day,
-                "scores": scores,
-                "notes": notes,
-            },
+        packed = json.dumps(
+            payload,
             ensure_ascii=False,
             separators=(",", ":"),
         ).encode("utf-8")
         descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
         try:
             with os.fdopen(descriptor, "wb") as handle:
-                handle.write(payload)
+                handle.write(packed)
@@
             raise
+
+
+def write_symptom_checkin(day: str, scores: dict[str, int], notes: str) -> None:
+    write_action_payload(
+        {
+            "version": 1,
+            "action": "symptom_checkin",
+            "date": day,
+            "scores": scores,
+            "notes": notes,
+        }
+    )
*** End Patch


Success. Updated the following files:
M scripts/health/health_dashboard_server.py

@@ -108,2 +108,3 @@
 CHECKIN_ROUTE = "/health-actions/symptom-checkin"
+NUTRITION_MAPPING_ROUTE = "/health-actions/nutrition-mapping"
 ACTION_INBOX = Path(
@@ -134,2 +135,6 @@
 SYMPTOM_FIELDS = ("aphthen", "gi", "fatigue", "skin", "eyes", "joints", "vascular")
+MAPPING_SCORES = {"0", "1", "2", "3", "unknown"}
+MAPPING_LIGHTS = {"green", "yellow", "orange", "red", "unknown"}
+MAPPING_SCOPES = {"canonical"}
+PERSONAL_TOLERANCE = {"unknown", "safe", "problematic", "unclear"}
 CSRF_TTL_SECONDS = max(
@@ -498,2 +503,46 @@

+def validate_mapping_submission(form: dict[str, list[str]]) -> tuple[dict[str, object], str]:
+    required = {
+        "csrf_token",
+        "queue_key",
+        "canonical_food",
+        "sighi_score",
+        "traffic_light",
+        "mapping_scope",
+        "personal_tolerance",
+        "return_to",
+    }
+    if frozenset(form) != frozenset(required) or any(len(values) != 1 for values in form.values()):
+        raise ValueError("invalid mapping shape")
+    return_to = form["return_to"][0]
+    if return_to != "v5":
+        raise ValueError("invalid return target")
+    queue_key = form["queue_key"][0].strip()
+    canonical_food = " ".join(form["canonical_food"][0].split())
+    score = form["sighi_score"][0]
+    traffic_light = form["traffic_light"][0]
+    scope = form["mapping_scope"][0]
+    tolerance = form["personal_tolerance"][0]
+    if not re.fullmatch(r"[a-z0-9_]{1,80}", queue_key):
+        raise ValueError("invalid queue key")
+    if len(canonical_food) > 120 or re.search(r"[\x00-\x1f\x7f]|https?://|/|\\\\|\.hermes", canonical_food, re.I):
+        raise ValueError("invalid canonical food")
+    if score not in MAPPING_SCORES or traffic_light not in MAPPING_LIGHTS or scope not in MAPPING_SCOPES or tolerance not in PERSONAL_TOLERANCE:
+        raise ValueError("invalid mapping value")
+    if score == "unknown" and traffic_light != "unknown":
+        raise ValueError("unknown score must keep unknown light")
+    if score != "unknown" and not canonical_food:
+        raise ValueError("canonical food required")
+    return {
+        "version": 1,
+        "action": "nutrition_mapping",
+        "queue_key": queue_key,
+        "canonical_food": canonical_food,
+        "sighi_score": None if score == "unknown" else int(score),
+        "traffic_light": traffic_light,
+        "mapping_scope": scope,
+        "personal_tolerance": tolerance,
+    }, return_to
+
+
 def ensure_private_inbox() -> None:
@@ -506,3 +555,3 @@

-def write_symptom_checkin(day: str, scores: dict[str, int], notes: str) -> None:
+def write_action_payload(payload: dict[str, object]) -> None:
     """Atomically enqueue a validated action; the network server never writes the DB."""
@@ -515,10 +564,4 @@
         destination = ACTION_INBOX / f"{token}.json"
-        payload = json.dumps(
-            {
-                "version": 1,
-                "action": "symptom_checkin",
-                "date": day,
-                "scores": scores,
-                "notes": notes,
-            },
+        packed = json.dumps(
+            payload,
             ensure_ascii=False,
@@ -529,3 +572,3 @@
             with os.fdopen(descriptor, "wb") as handle:
-                handle.write(payload)
+                handle.write(packed)
                 handle.flush()
@@ -543,2 +586,14 @@

+def write_symptom_checkin(day: str, scores: dict[str, int], notes: str) -> None:
+    write_action_payload(
+        {
+            "version": 1,
+            "action": "symptom_checkin",
+            "date": day,
+            "scores": scores,
+            "notes": notes,
+        }
+    )
+
+
 class Handler(BaseHTTPRequestHandler):


Output:
Exit code: 0
Wall time: 0.1 seconds
Output:
Success. Updated the following files:
M scripts/health/health_dashboard_server.py


Input:
*** Begin Patch
*** Update File: scripts/health/health_dashboard_server.py
@@
-        if urlparse(self.path).path != CHECKIN_ROUTE:
+        action_path = urlparse(self.path).path
+        if action_path not in {CHECKIN_ROUTE, NUTRITION_MAPPING_ROUTE}:
             self.send_error(404)
             return
@@
         if not consume_csrf_token(token):
             self.send_error(403)
             return
         try:
-            day, scores, notes, return_to = validate_symptom_submission(form)
-            write_symptom_checkin(day, scores, notes)
+            if action_path == CHECKIN_ROUTE:
+                day, scores, notes, return_to = validate_symptom_submission(form)
+                write_symptom_checkin(day, scores, notes)
+            else:
+                payload, return_to = validate_mapping_submission(form)
+                write_action_payload(payload)
         except ValueError:
             self.send_error(400)
             return
@@
-        if return_to == "v5":
+        if return_to == "v5":
             receipt = quote(issue_queue_receipt(), safe="")
-            target = V5_ROUTE + f"?queued={receipt}#capture-status"
+            target = V5_ROUTE + f"?queued={receipt}#capture-status"
         else:
*** End Patch


@@ -668,3 +668,4 @@
             return
-        if urlparse(self.path).path != CHECKIN_ROUTE:
+        action_path = urlparse(self.path).path
+        if action_path not in {CHECKIN_ROUTE, NUTRITION_MAPPING_ROUTE}:
             self.send_error(404)
@@ -710,4 +711,8 @@
         try:
-            day, scores, notes, return_to = validate_symptom_submission(form)
-            write_symptom_checkin(day, scores, notes)
+            if action_path == CHECKIN_ROUTE:
+                day, scores, notes, return_to = validate_symptom_submission(form)
+                write_symptom_checkin(day, scores, notes)
+            else:
+                payload, return_to = validate_mapping_submission(form)
+                write_action_payload(payload)
         except ValueError:


Output:
Exit code: 0
Wall time: 0.1 seconds
Output:
Success. Updated the following files:
M scripts/health/health_dashboard_server.py


The server now queues a second private action type with the same same-origin, CSRF, and bounded inbox controls. The worker changes are next, including additive provenance tables and idempotent transaction handling.


Input:
*** Begin Patch
*** Update File: scripts/health/health_dashboard_action_worker.py
@@
 import json
 import os
 import re
 import stat
 import subprocess
 import sys
+import sqlite3
+import hashlib
 from datetime import date, datetime
 from pathlib import Path
 from typing import Any
@@
 FIELDS = ("aphthen", "gi", "fatigue", "skin", "eyes", "joints", "vascular")
 NAME_RE = re.compile(r"[0-9a-f]{32}\.json")
 LOCAL_TIMEZONE = ZoneInfo("Europe/Zurich")
@@
-def validate_payload(payload: Any) -> tuple[str, dict[str, int], str]:
-    if not isinstance(payload, dict) or set(payload) != {
+def validate_symptom_payload(payload: dict[str, Any]) -> dict[str, Any]:
+    if set(payload) != {
         "version",
         "action",
         "date",
         "scores",
         "notes",
     }:
         raise ValueError("invalid payload shape")
@@
-    return day.isoformat(), {field: scores[field] for field in FIELDS}, notes.strip()
+    return {
+        "version": 1,
+        "action": "symptom_checkin",
+        "date": day.isoformat(),
+        "scores": {field: scores[field] for field in FIELDS},
+        "notes": notes.strip(),
+    }
+
+
+def validate_mapping_payload(payload: dict[str, Any]) -> dict[str, Any]:
+    if set(payload) != {
+        "version",
+        "action",
+        "queue_key",
+        "canonical_food",
+        "sighi_score",
+        "traffic_light",
+        "mapping_scope",
+        "personal_tolerance",
+    }:
+        raise ValueError("invalid mapping payload shape")
+    if payload["version"] != 1 or payload["action"] != "nutrition_mapping":
+        raise ValueError("unsupported action")
+    queue_key = payload["queue_key"]
+    canonical = " ".join(str(payload["canonical_food"] or "").split())
+    score = payload["sighi_score"]
+    light = payload["traffic_light"]
+    scope = payload["mapping_scope"]
+    tolerance = payload["personal_tolerance"]
+    if not isinstance(queue_key, str) or not re.fullmatch(r"[a-z0-9_]{1,80}", queue_key):
+        raise ValueError("invalid queue key")
+    if not isinstance(canonical, str) or len(canonical) > 120 or re.search(r"[\x00-\x1f\x7f]|https?://|/|\\\\|\.hermes", canonical, re.I):
+        raise ValueError("invalid canonical food")
+    if score is not None and (type(score) is not int or score not in range(4)):
+        raise ValueError("invalid score")
+    if light not in {"green", "yellow", "orange", "red", "unknown"} or scope != "canonical" or tolerance not in {"unknown", "safe", "problematic", "unclear"}:
+        raise ValueError("invalid mapping value")
+    if score is None and light != "unknown":
+        raise ValueError("unknown score must keep unknown light")
+    if score is not None and not canonical:
+        raise ValueError("canonical food required")
+    return {
+        "version": 1,
+        "action": "nutrition_mapping",
+        "queue_key": queue_key,
+        "canonical_food": canonical,
+        "sighi_score": score,
+        "traffic_light": light,
+        "mapping_scope": scope,
+        "personal_tolerance": tolerance,
+    }
+
+
+def validate_payload(payload: Any) -> dict[str, Any]:
+    if not isinstance(payload, dict):
+        raise ValueError("invalid payload shape")
+    action = payload.get("action")
+    if action == "symptom_checkin":
+        return validate_symptom_payload(payload)
+    if action == "nutrition_mapping":
+        return validate_mapping_payload(payload)
+    raise ValueError("unsupported action")
@@
-def load_action(path: Path) -> tuple[str, dict[str, int], str]:
+def load_action(path: Path) -> dict[str, Any]:
@@
     return validate_payload(
         json.loads(raw.decode("utf-8"), object_pairs_hook=reject_duplicate_object_pairs)
     )

-def apply_action(day: str, scores: dict[str, int], notes: str) -> None:
+def apply_symptom_action(day: str, scores: dict[str, int], notes: str) -> None:
@@
             text=True,
         )
+
+
+def _slug(value: str) -> str:
+    normalized = value.casefold().translate(str.maketrans({"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss"}))
+    return re.sub(r"[^a-z0-9]+", "_", normalized).strip("_")
+
+
+def _score_label(max_score: int, unknown: int, load: float) -> str:
+    if max_score >= 3:
+        return "red"
+    if max_score >= 2 or load >= 4:
+        return "orange"
+    if max_score == 1 or load >= 1:
+        return "yellow"
+    if unknown:
+        return "unknown"
+    return "green"
+
+
+def ensure_nutrition_mapping_schema(connection: sqlite3.Connection) -> None:
+    connection.executescript(
+        """
+        CREATE TABLE IF NOT EXISTS nutrition_mapping_action_log (
+            action_id TEXT PRIMARY KEY,
+            action_hash TEXT NOT NULL,
+            queue_key TEXT NOT NULL,
+            applied_at TEXT DEFAULT CURRENT_TIMESTAMP
+        );
+        CREATE TABLE IF NOT EXISTS nutrition_mapping_provenance (
+            id INTEGER PRIMARY KEY AUTOINCREMENT,
+            action_id TEXT NOT NULL,
+            normalized_name TEXT NOT NULL,
+            canonical_food TEXT,
+            sighi_score INTEGER,
+            traffic_light TEXT NOT NULL,
+            mapping_scope TEXT NOT NULL,
+            affected_day TEXT NOT NULL,
+            applied_at TEXT DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE(action_id, affected_day)
+        );
+        CREATE TABLE IF NOT EXISTS nutrition_composite_product_review (
+            normalized_name TEXT PRIMARY KEY,
+            canonical_food TEXT,
+            status TEXT NOT NULL DEFAULT 'needs_ingredient_review',
+            updated_at TEXT DEFAULT CURRENT_TIMESTAMP
+        );
+        """
+    )
+
+
+def recompute_nutrition_day(connection: sqlite3.Connection, day: str) -> None:
+    rows = list(
+        connection.execute(
+            """SELECT i.meal,i.kcal,i.protein_g,i.carb_g,i.fat_g,h.sighi_score
+               FROM nutrition_items i LEFT JOIN nutrition_histamine_scores h ON h.item_id=i.id
+               WHERE i.datum=?""",
+            (day,),
+        )
+    )
+    connection.execute("DELETE FROM nutrition_meal_summary WHERE datum=?", (day,))
+    if not rows:
+        connection.execute("DELETE FROM nutrition_daily_summary_v2 WHERE datum=?", (day,))
+        return
+    totals = {"kcal": 0.0, "protein": 0.0, "carb": 0.0, "fat": 0.0, "items": 0, "unknown": 0, "load": 0.0, "scores": []}
+    by_meal: dict[str, dict[str, Any]] = {}
+    for row in rows:
+        meal = row["meal"] or "unknown"
+        bucket = by_meal.setdefault(meal, {"kcal": 0.0, "protein": 0.0, "carb": 0.0, "fat": 0.0, "items": 0, "unknown": 0, "load": 0.0, "scores": []})
+        for target in (bucket, totals):
+            target["items"] += 1
+            target["kcal"] += float(row["kcal"] or 0)
+            target["protein"] += float(row["protein_g"] or 0)
+            target["carb"] += float(row["carb_g"] or 0)
+            target["fat"] += float(row["fat_g"] or 0)
+            if row["sighi_score"] is None:
+                target["unknown"] += 1
+            else:
+                score = int(row["sighi_score"])
+                target["scores"].append(score)
+                target["load"] += score
+    for meal, bucket in by_meal.items():
+        max_score = max(bucket["scores"] or [0])
+        label = _score_label(max_score, int(bucket["unknown"]), float(bucket["load"]))
+        connection.execute(
+            """INSERT OR REPLACE INTO nutrition_meal_summary
+               (datum,meal,kcal,protein_g,carb_g,fat_g,item_count,nutrient_json,histamine_score,histamine_label,updated_at)
+               VALUES(?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)""",
+            (day, meal, round(bucket["kcal"], 1), round(bucket["protein"], 1), round(bucket["carb"], 1), round(bucket["fat"], 1), int(bucket["items"]), "{}", round(bucket["load"], 1), label),
+        )
+    max_score = max(totals["scores"] or [0])
+    label = _score_label(max_score, int(totals["unknown"]), float(totals["load"]))
+    connection.execute(
+        """INSERT OR REPLACE INTO nutrition_daily_summary_v2
+           (datum,kcal,protein_g,carb_g,fat_g,item_count,nutrient_json,histamine_score,histamine_max,histamine_unknown_count,histamine_label,updated_at)
+           VALUES(?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)""",
+        (day, round(totals["kcal"], 1), round(totals["protein"], 1), round(totals["carb"], 1), round(totals["fat"], 1), int(totals["items"]), "{}", round(totals["load"], 1), max_score, int(totals["unknown"]), label),
+    )
+
+
+def apply_mapping_action(payload: dict[str, Any], action_id: str) -> None:
+    if DASHBOARD_DB is None:
+        raise RuntimeError("HEALTH_DASHBOARD_DB is required")
+    action_hash = hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
+    connection = sqlite3.connect(DASHBOARD_DB)
+    connection.row_factory = sqlite3.Row
+    try:
+        with connection:
+            ensure_nutrition_mapping_schema(connection)
+            existing = connection.execute("SELECT action_hash FROM nutrition_mapping_action_log WHERE action_id=?", (action_id,)).fetchone()
+            if existing is not None:
+                if existing["action_hash"] != action_hash:
+                    raise RuntimeError("conflicting idempotency key")
+                return
+            queue = connection.execute("SELECT normalized_name,example_name FROM nutrition_review_queue WHERE status='open'").fetchall()
+            matched = [row for row in queue if _slug(str(row["normalized_name"])) == payload["queue_key"]]
+            if len(matched) != 1:
+                raise RuntimeError("mapping queue item not found")
+            normalized = str(matched[0]["normalized_name"])
+            canonical = payload["canonical_food"]
+            score = payload["sighi_score"]
+            light = payload["traffic_light"]
+            affected = sorted({row["datum"] for row in connection.execute("SELECT datum FROM nutrition_items WHERE lower(name)=lower(?)", (matched[0]["example_name"],)) if row["datum"]})
+            if len(affected) > 366:
+                raise RuntimeError("affected day limit exceeded")
+            if re.search(r"\+|,|;|\\bmit\\b|\\bund\\b", str(matched[0]["example_name"]), re.I):
+                connection.execute(
+                    """INSERT OR REPLACE INTO nutrition_composite_product_review
+                       (normalized_name,canonical_food,status,updated_at)
+                       VALUES(?,?, 'needs_ingredient_review', CURRENT_TIMESTAMP)""",
+                    (normalized, canonical),
+                )
+                score = None
+                light = "unknown"
+            for row in connection.execute("SELECT id FROM nutrition_items WHERE lower(name)=lower(?)", (matched[0]["example_name"],)):
+                connection.execute(
+                    """INSERT OR REPLACE INTO nutrition_histamine_scores
+                       (item_id,canonical_food,sighi_score,traffic_light,tags,confidence,reason,scored_at)
+                       VALUES(?,?,?,?,?,?,?,CURRENT_TIMESTAMP)""",
+                    (row["id"], canonical or None, score, light, "worker_mapping_v1", "medium", "local validated mapping action"),
+                )
+            connection.execute("UPDATE nutrition_review_queue SET status='mapped',updated_at=CURRENT_TIMESTAMP WHERE normalized_name=?", (normalized,))
+            if payload["personal_tolerance"] != "unknown" and canonical:
+                connection.execute(
+                    """INSERT INTO personal_food_tolerance(canonical_food,personal_status,evidence_level,notes,updated_at)
+                       VALUES(?,?, 'low', 'separate personal tolerance label from mapping action', CURRENT_TIMESTAMP)
+                       ON CONFLICT(canonical_food) DO UPDATE SET personal_status=excluded.personal_status,updated_at=CURRENT_TIMESTAMP""",
+                    (canonical, payload["personal_tolerance"]),
+                )
+            for day in affected:
+                recompute_nutrition_day(connection, str(day))
+                connection.execute(
+                    """INSERT OR IGNORE INTO nutrition_mapping_provenance
+                       (action_id,normalized_name,canonical_food,sighi_score,traffic_light,mapping_scope,affected_day)
+                       VALUES(?,?,?,?,?,?,?)""",
+                    (action_id, normalized, canonical or None, score, light, payload["mapping_scope"], str(day)),
+                )
+            connection.execute(
+                "INSERT INTO nutrition_mapping_action_log(action_id,action_hash,queue_key) VALUES(?,?,?)",
+                (action_id, action_hash, payload["queue_key"]),
+            )
+    finally:
+        connection.close()
+
+
+def apply_action(payload: dict[str, Any], action_id: str) -> None:
+    if payload["action"] == "symptom_checkin":
+        apply_symptom_action(payload["date"], payload["scores"], payload["notes"])
+    elif payload["action"] == "nutrition_mapping":
+        apply_mapping_action(payload, action_id)
+    else:
+        raise ValueError("unsupported action")
@@
-            day, scores, notes = load_action(path)
-            apply_action(day, scores, notes)
+            payload = load_action(path)
+            apply_action(payload, path.stem)
             path.unlink()
*** End Patch


Success. Updated the following files:
M scripts/health/health_dashboard_action_worker.py

@@ -11,2 +11,4 @@
 import sys
+import sqlite3
+import hashlib
 from datetime import date, datetime
@@ -67,4 +69,4 @@

-def validate_payload(payload: Any) -> tuple[str, dict[str, int], str]:
-    if not isinstance(payload, dict) or set(payload) != {
+def validate_symptom_payload(payload: dict[str, Any]) -> dict[str, Any]:
+    if set(payload) != {
         "version",
@@ -95,3 +97,64 @@
         raise ValueError("invalid notes")
-    return day.isoformat(), {field: scores[field] for field in FIELDS}, notes.strip()
+    return {
+        "version": 1,
+        "action": "symptom_checkin",
+        "date": day.isoformat(),
+        "scores": {field: scores[field] for field in FIELDS},
+        "notes": notes.strip(),
+    }
+
+
+def validate_mapping_payload(payload: dict[str, Any]) -> dict[str, Any]:
+    if set(payload) != {
+        "version",
+        "action",
+        "queue_key",
+        "canonical_food",
+        "sighi_score",
+        "traffic_light",
+        "mapping_scope",
+        "personal_tolerance",
+    }:
+        raise ValueError("invalid mapping payload shape")
+    if payload["version"] != 1 or payload["action"] != "nutrition_mapping":
+        raise ValueError("unsupported action")
+    queue_key = payload["queue_key"]
+    canonical = " ".join(str(payload["canonical_food"] or "").split())
+    score = payload["sighi_score"]
+    light = payload["traffic_light"]
+    scope = payload["mapping_scope"]
+    tolerance = payload["personal_tolerance"]
+    if not isinstance(queue_key, str) or not re.fullmatch(r"[a-z0-9_]{1,80}", queue_key):
+        raise ValueError("invalid queue key")
+    if not isinstance(canonical, str) or len(canonical) > 120 or re.search(r"[\x00-\x1f\x7f]|https?://|/|\\\\|\.hermes", canonical, re.I):
+        raise ValueError("invalid canonical food")
+    if score is not None and (type(score) is not int or score not in range(4)):
+        raise ValueError("invalid score")
+    if light not in {"green", "yellow", "orange", "red", "unknown"} or scope != "canonical" or tolerance not in {"unknown", "safe", "problematic", "unclear"}:
+        raise ValueError("invalid mapping value")
+    if score is None and light != "unknown":
+        raise ValueError("unknown score must keep unknown light")
+    if score is not None and not canonical:
+        raise ValueError("canonical food required")
+    return {
+        "version": 1,
+        "action": "nutrition_mapping",
+        "queue_key": queue_key,
+        "canonical_food": canonical,
+        "sighi_score": score,
+        "traffic_light": light,
+        "mapping_scope": scope,
+        "personal_tolerance": tolerance,
+    }
+
+
+def validate_payload(payload: Any) -> dict[str, Any]:
+    if not isinstance(payload, dict):
+        raise ValueError("invalid payload shape")
+    action = payload.get("action")
+    if action == "symptom_checkin":
+        return validate_symptom_payload(payload)
+    if action == "nutrition_mapping":
+        return validate_mapping_payload(payload)
+    raise ValueError("unsupported action")

@@ -115,3 +178,3 @@

-def load_action(path: Path) -> tuple[str, dict[str, int], str]:
+def load_action(path: Path) -> dict[str, Any]:
     if not NAME_RE.fullmatch(path.name):
@@ -139,3 +202,3 @@

-def apply_action(day: str, scores: dict[str, int], notes: str) -> None:
+def apply_symptom_action(day: str, scores: dict[str, int], notes: str) -> None:
     if DASHBOARD_DB is None:
@@ -163,2 +226,173 @@
         )
+
+
+def _slug(value: str) -> str:
+    normalized = value.casefold().translate(str.maketrans({"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss"}))
+    return re.sub(r"[^a-z0-9]+", "_", normalized).strip("_")
+
+
+def _score_label(max_score: int, unknown: int, load: float) -> str:
+    if max_score >= 3:
+        return "red"
+    if max_score >= 2 or load >= 4:
+        return "orange"
+    if max_score == 1 or load >= 1:
+        return "yellow"
+    if unknown:
+        return "unknown"
+    return "green"
+
+
+def ensure_nutrition_mapping_schema(connection: sqlite3.Connection) -> None:
+    connection.executescript(
+        """
+        CREATE TABLE IF NOT EXISTS nutrition_mapping_action_log (
+            action_id TEXT PRIMARY KEY,
+            action_hash TEXT NOT NULL,
+            queue_key TEXT NOT NULL,
+            applied_at TEXT DEFAULT CURRENT_TIMESTAMP
+        );
+        CREATE TABLE IF NOT EXISTS nutrition_mapping_provenance (
+            id INTEGER PRIMARY KEY AUTOINCREMENT,
+            action_id TEXT NOT NULL,
+            normalized_name TEXT NOT NULL,
+            canonical_food TEXT,
+            sighi_score INTEGER,
+            traffic_light TEXT NOT NULL,
+            mapping_scope TEXT NOT NULL,
+            affected_day TEXT NOT NULL,
+            applied_at TEXT DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE(action_id, affected_day)
+        );
+        CREATE TABLE IF NOT EXISTS nutrition_composite_product_review (
+            normalized_name TEXT PRIMARY KEY,
+            canonical_food TEXT,
+            status TEXT NOT NULL DEFAULT 'needs_ingredient_review',
+            updated_at TEXT DEFAULT CURRENT_TIMESTAMP
+        );
+        """
+    )
+
+
+def recompute_nutrition_day(connection: sqlite3.Connection, day: str) -> None:
+    rows = list(
+        connection.execute(
+            """SELECT i.meal,i.kcal,i.protein_g,i.carb_g,i.fat_g,h.sighi_score
+               FROM nutrition_items i LEFT JOIN nutrition_histamine_scores h ON h.item_id=i.id
+               WHERE i.datum=?""",
+            (day,),
+        )
+    )
+    connection.execute("DELETE FROM nutrition_meal_summary WHERE datum=?", (day,))
+    if not rows:
+        connection.execute("DELETE FROM nutrition_daily_summary_v2 WHERE datum=?", (day,))
+        return
+    totals = {"kcal": 0.0, "protein": 0.0, "carb": 0.0, "fat": 0.0, "items": 0, "unknown": 0, "load": 0.0, "scores": []}
+    by_meal: dict[str, dict[str, Any]] = {}
+    for row in rows:
+        meal = row["meal"] or "unknown"
+        bucket = by_meal.setdefault(meal, {"kcal": 0.0, "protein": 0.0, "carb": 0.0, "fat": 0.0, "items": 0, "unknown": 0, "load": 0.0, "scores": []})
+        for target in (bucket, totals):
+            target["items"] += 1
+            target["kcal"] += float(row["kcal"] or 0)
+            target["protein"] += float(row["protein_g"] or 0)
+            target["carb"] += float(row["carb_g"] or 0)
+            target["fat"] += float(row["fat_g"] or 0)
+            if row["sighi_score"] is None:
+                target["unknown"] += 1
+            else:
+                score = int(row["sighi_score"])
+                target["scores"].append(score)
+                target["load"] += score
+    for meal, bucket in by_meal.items():
+        max_score = max(bucket["scores"] or [0])
+        label = _score_label(max_score, int(bucket["unknown"]), float(bucket["load"]))
+        connection.execute(
+            """INSERT OR REPLACE INTO nutrition_meal_summary
+               (datum,meal,kcal,protein_g,carb_g,fat_g,item_count,nutrient_json,histamine_score,histamine_label,updated_at)
+               VALUES(?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)""",
+            (day, meal, round(bucket["kcal"], 1), round(bucket["protein"], 1), round(bucket["carb"], 1), round(bucket["fat"], 1), int(bucket["items"]), "{}", round(bucket["load"], 1), label),
+        )
+    max_score = max(totals["scores"] or [0])
+    label = _score_label(max_score, int(totals["unknown"]), float(totals["load"]))
+    connection.execute(
+        """INSERT OR REPLACE INTO nutrition_daily_summary_v2
+           (datum,kcal,protein_g,carb_g,fat_g,item_count,nutrient_json,histamine_score,histamine_max,histamine_unknown_count,histamine_label,updated_at)
+           VALUES(?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)""",
+        (day, round(totals["kcal"], 1), round(totals["protein"], 1), round(totals["carb"], 1), round(totals["fat"], 1), int(totals["items"]), "{}", round(totals["load"], 1), max_score, int(totals["unknown"]), label),
+    )
+
+
+def apply_mapping_action(payload: dict[str, Any], action_id: str) -> None:
+    if DASHBOARD_DB is None:
+        raise RuntimeError("HEALTH_DASHBOARD_DB is required")
+    action_hash = hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
+    connection = sqlite3.connect(DASHBOARD_DB)
+    connection.row_factory = sqlite3.Row
+    try:
+        with connection:
+            ensure_nutrition_mapping_schema(connection)
+            existing = connection.execute("SELECT action_hash FROM nutrition_mapping_action_log WHERE action_id=?", (action_id,)).fetchone()
+            if existing is not None:
+                if existing["action_hash"] != action_hash:
+                    raise RuntimeError("conflicting idempotency key")
+                return
+            queue = connection.execute("SELECT normalized_name,example_name FROM nutrition_review_queue WHERE status='open'").fetchall()
+            matched = [row for row in queue if _slug(str(row["normalized_name"])) == payload["queue_key"]]
+            if len(matched) != 1:
+                raise RuntimeError("mapping queue item not found")
+            normalized = str(matched[0]["normalized_name"])
+            canonical = payload["canonical_food"]
+            score = payload["sighi_score"]
+            light = payload["traffic_light"]
+            affected = sorted({row["datum"] for row in connection.execute("SELECT datum FROM nutrition_items WHERE lower(name)=lower(?)", (matched[0]["example_name"],)) if row["datum"]})
+            if len(affected) > 366:
+                raise RuntimeError("affected day limit exceeded")
+            if re.search(r"\+|,|;|\\bmit\\b|\\bund\\b", str(matched[0]["example_name"]), re.I):
+                connection.execute(
+                    """INSERT OR REPLACE INTO nutrition_composite_product_review
+                       (normalized_name,canonical_food,status,updated_at)
+                       VALUES(?,?, 'needs_ingredient_review', CURRENT_TIMESTAMP)""",
+                    (normalized, canonical),
+                )
+                score = None
+                light = "unknown"
+            for row in connection.execute("SELECT id FROM nutrition_items WHERE lower(name)=lower(?)", (matched[0]["example_name"],)):
+                connection.execute(
+                    """INSERT OR REPLACE INTO nutrition_histamine_scores
+                       (item_id,canonical_food,sighi_score,traffic_light,tags,confidence,reason,scored_at)
+                       VALUES(?,?,?,?,?,?,?,CURRENT_TIMESTAMP)""",
+                    (row["id"], canonical or None, score, light, "worker_mapping_v1", "medium", "local validated mapping action"),
+                )
+            connection.execute("UPDATE nutrition_review_queue SET status='mapped',updated_at=CURRENT_TIMESTAMP WHERE normalized_name=?", (normalized,))
+            if payload["personal_tolerance"] != "unknown" and canonical:
+                connection.execute(
+                    """INSERT INTO personal_food_tolerance(canonical_food,personal_status,evidence_level,notes,updated_at)
+                       VALUES(?,?, 'low', 'separate personal tolerance label from mapping action', CURRENT_TIMESTAMP)
+                       ON CONFLICT(canonical_food) DO UPDATE SET personal_status=excluded.personal_status,updated_at=CURRENT_TIMESTAMP""",
+                    (canonical, payload["personal_tolerance"]),
+                )
+            for day in affected:
+                recompute_nutrition_day(connection, str(day))
+                connection.execute(
+                    """INSERT OR IGNORE INTO nutrition_mapping_provenance
+                       (action_id,normalized_name,canonical_food,sighi_score,traffic_light,mapping_scope,affected_day)
+                       VALUES(?,?,?,?,?,?,?)""",
+                    (action_id, normalized, canonical or None, score, light, payload["mapping_scope"], str(day)),
+                )
+            connection.execute(
+                "INSERT INTO nutrition_mapping_action_log(action_id,action_hash,queue_key) VALUES(?,?,?)",
+                (action_id, action_hash, payload["queue_key"]),
+            )
+    finally:
+        connection.close()
+
+
+def apply_action(payload: dict[str, Any], action_id: str) -> None:
+    if payload["action"] == "symptom_checkin":
+        apply_symptom_action(payload["date"], payload["scores"], payload["notes"])
+    elif payload["action"] == "nutrition_mapping":
+        apply_mapping_action(payload, action_id)
+    else:
+        raise ValueError("unsupported action")
     if DASHBOARD_V5_FILE is not None:
@@ -193,4 +427,4 @@
         try:
-            day, scores, notes = load_action(path)
-            apply_action(day, scores, notes)
+            payload = load_action(path)
+            apply_action(payload, path.stem)
             path.unlink()


Output:
Exit code: 0
Wall time: 0.1 seconds
Output:
Success. Updated the following files:
M scripts/health/health_dashboard_action_worker.py


sed -n '1,320p' scripts/health/health_dashboard_action_worker.py