#!/usr/bin/env python3
"""JARVIS Health Dashboard v4.

Mobile, privacy-hardened evolution of the complete v3 dashboard:
- laboratory trend charts with reference ranges and clinical-event markers
- Apple Health Auto Export modules (sleep/HRV/pulse/activity/SpO2/respiration)
- nutrition adherence
- doctor-report PDF link / Drive upload metadata
"""
from __future__ import annotations

import argparse
import html
import json
import os
import re
import sqlite3
import sys
from collections import defaultdict
from datetime import datetime, timedelta
from pathlib import Path
from statistics import mean, median
from typing import Any, Mapping

BASE = Path.home() / ".hermes" / "assets" / "Gesundheit"
DB = BASE / "health_data.db"
REPORTS = BASE / "reports"

sys.path.insert(0, str(Path(__file__).resolve().parent))
from health_pipeline import best_reference_xlsx, parse_reference_xlsx, get_lab_matrix, split_reference, db_counts  # noqa: E402
from apple_health_analytics import (  # noqa: E402
    ANALYTICS_VERSION as APPLE_ANALYTICS_VERSION,
    LOCAL_TZ as APPLE_LOCAL_TZ,
    coverage_summary as apple_coverage_summary,
    daily_series as canonical_apple_daily,
    unit_for as canonical_apple_unit,
)

APPLE_CHARTS = [
    ("step_count", "Schritte", "sum"),
    ("walking_running_distance", "Geh-/Laufdistanz", "sum"),
    ("resting_heart_rate", "Ruhepuls", "avg"),
    ("heart_rate", "Herzfrequenz", "avg"),
    ("heart_rate_variability", "HRV", "avg"),
    ("sleep_analysis", "Schlafdauer", "sum"),
    ("blood_oxygen_saturation", "SpO₂", "avg"),
    ("respiratory_rate", "Atemfrequenz", "avg"),
    ("active_energy", "Aktive Energie", "sum"),
    ("physical_effort", "Physical Effort", "avg"),
    ("weight_body_mass", "Gewicht", "last"),
]


def conn() -> sqlite3.Connection:
    c = sqlite3.connect(DB)
    c.row_factory = sqlite3.Row
    return c


def calendar_axis(day_values: Any, max_days: int | None = None) -> list[str]:
    parsed = []
    for value in day_values:
        try:
            parsed.append(datetime.strptime(str(value)[:10], "%Y-%m-%d").date())
        except ValueError:
            continue
    if not parsed:
        return []
    end = max(parsed)
    start = min(parsed)
    if max_days is not None:
        start = max(start, end - timedelta(days=max_days - 1))
    return [
        (start + timedelta(days=offset)).isoformat()
        for offset in range((end - start).days + 1)
    ]


def sparse_calendar_series(days: list[str], observed: dict[str, Any]) -> list[Any]:
    return [observed.get(day) for day in days]


def rolling_median_baseline(
    days: list[str], observed: Mapping[str, float | int], *, window_days: int = 14, min_observations: int = 7
) -> list[float | None]:
    parsed = [datetime.strptime(day, "%Y-%m-%d").date() for day in days]
    result: list[float | None] = []
    for current in parsed:
        start = current - timedelta(days=window_days - 1)
        values = [
            float(value)
            for day, value in observed.items()
            if start <= datetime.strptime(day, "%Y-%m-%d").date() <= current
        ]
        result.append(round(median(values), 3) if len(values) >= min_observations else None)
    return result


def format_measurement(value: Any, precision: int = 1, suffix: str = "") -> str:
    if value is None:
        return "unbekannt"
    return f"{float(value):.{precision}f}{suffix}"


def complete_histamine(row: sqlite3.Row | None) -> tuple[str, str]:
    if row is None:
        return "unknown", "unbekannt"
    keys = set(row.keys())
    unknown_count = row["histamine_unknown_count"] if "histamine_unknown_count" in keys else None
    item_count = row["item_count"] if "item_count" in keys else None
    score = row["histamine_score"] if "histamine_score" in keys else None
    label = row["histamine_label"] if "histamine_label" in keys else None
    if (
        unknown_count is None
        or item_count is None
        or int(item_count) < 1
        or int(unknown_count) != 0
        or score is None
        or label not in {"green", "yellow", "orange", "red"}
    ):
        return "unknown", "unbekannt"
    return str(label), format_measurement(score, 1)


def item_histamine_label(row: sqlite3.Row) -> str:
    traffic = str(row["traffic_light"] or "").strip().casefold()
    canonical_food = str(row["canonical_food"] or "").strip()
    confidence = str(row["confidence"] or "").strip().casefold()
    try:
        score = float(row["sighi_score"])
    except (TypeError, ValueError):
        return "unknown"
    if (
        traffic not in {"green", "yellow", "orange", "red"}
        or not canonical_food
        or canonical_food.casefold() in {"unknown", "unbekannt"}
        or confidence not in {"medium", "high", "mittel", "hoch", "verified"}
        or not 0 <= score <= 3
    ):
        return "unknown"
    return traffic


def is_administered_event(event_type: Any) -> bool:
    return str(event_type or "").strip().casefold() in {"administered", "verabreicht"}


def source_freshness(
    latest_day: str | None, *, today: str, expected_within_days: int
) -> tuple[str, int | None]:
    if not latest_day:
        return "unbekannt", None
    text = str(latest_day).strip()
    try:
        if re.fullmatch(r"\d{4}-\d{2}-\d{2}", text):
            latest = datetime.strptime(text, "%Y-%m-%d").date()
        elif re.fullmatch(
            r"\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?(?:Z|[+-]\d{2}:\d{2})?",
            text,
        ):
            latest = datetime.fromisoformat(text.replace("Z", "+00:00")).date()
        else:
            return "unbekannt", None
        current = datetime.strptime(today, "%Y-%m-%d").date()
    except ValueError:
        return "unbekannt", None
    age = (current - latest).days
    if age < 0:
        return "prüfen", age
    return ("aktuell" if age <= expected_within_days else "prüfen"), age


def resolve_doc_path(*values: Any) -> Path | None:
    for value in values:
        if not value:
            continue
        raw = str(value)
        for p in [Path(os.path.expanduser(raw)), BASE / raw, Path("/home/agent") / raw, Path(raw.replace("/home/agent/Gesundheit", str(BASE)))]:
            try:
                if p.exists():
                    return p.resolve()
            except OSError:
                pass
    return None


def doc_url(row: sqlite3.Row) -> str | None:
    """Return browser-openable URL for dashboard documents.

    iPhone/Safari cannot open local file:// links from the Tailscale dashboard,
    so local HealthManager docs are exposed via the dashboard's restricted
    /health-doc/<id> route. Drive remains only a fallback.
    """
    keys = row.keys()
    vals = []
    if "local_original_path" in keys:
        vals.append(row["local_original_path"])
    if "dateipfad" in keys:
        vals.append(row["dateipfad"])
    p = resolve_doc_path(*vals)
    if p and "id" in keys:
        return f"/health-doc/{int(row['id'])}"
    if "drive_web_url" in keys and row["drive_web_url"]:
        return str(row["drive_web_url"])
    return None


def link_doc(row: sqlite3.Row) -> str:
    name = html.escape(str(row["datei_name"]))
    url = doc_url(row)
    return f"<a href='{html.escape(url)}' target='_blank'>{name}</a>" if url else name


def num(x: Any) -> float | None:
    try:
        return float(str(x).replace(",", ".").replace("<", "").replace(">", "").strip())
    except Exception:
        return None


def row_value(row: Any, key: str, default: Any = None) -> Any:
    try:
        value = row[key]
    except (KeyError, IndexError, TypeError):
        return default
    return default if value is None else value


def parse_comparison_value(raw: Any) -> tuple[str, float] | None:
    match = re.fullmatch(r"\s*(<=|>=|<|>|=)?\s*([-+]?\d+(?:[.,]\d+)?)\s*", str(raw or ""))
    if not match:
        return None
    return match.group(1) or "=", float(match.group(2).replace(",", "."))


def evaluate_lab_warning(row: Any) -> tuple[str, str, str] | None:
    """Return a conservative reference-range warning for verified lab data only."""
    if str(row_value(row, "validierungsstatus", "")).lower() != "validiert":
        return None
    if int(row_value(row, "verified_against_original", 0) or 0) != 1:
        return None
    if str(row_value(row, "reference_range_source", "")).lower() != "scanned_original":
        return None
    unit = str(row_value(row, "einheit", "")).strip()
    parsed = parse_comparison_value(row_value(row, "wert"))
    lower = num(row_value(row, "reference_min"))
    upper = num(row_value(row, "reference_max"))
    if not unit or parsed is None or (lower is None and upper is None):
        return None
    operator, value = parsed
    direction = None
    if operator == "=" and upper is not None and value > upper:
        direction = "über"
    elif operator == "=" and lower is not None and value < lower:
        direction = "unter"
    elif operator == ">" and upper is not None and value >= upper:
        direction = "über"
    elif operator == ">=" and upper is not None and value > upper:
        direction = "über"
    elif operator == "<" and lower is not None and value <= lower:
        direction = "unter"
    elif operator == "<=" and lower is not None and value < lower:
        direction = "unter"
    if direction is None:
        return None
    name = str(row_value(row, "parameter_name", "Laborwert"))
    date = str(row_value(row, "abnahme_datum", row_value(row, "befund_datum", "")))
    reference = f"{lower if lower is not None else '–'} bis {upper if upper is not None else '–'} {unit}"
    display_value = f"{operator if operator != '=' else ''}{value:g} {unit}".strip()
    return (
        "orange",
        f"Validierten Laborwert ärztlich prüfen: {name}",
        f"{display_value} liegt sicher {direction} dem Referenzbereich ({reference}); Messdatum {date}.",
    )


def ref_nums(ref: str | None) -> tuple[float | None, float | None]:
    rmin, rmax, _ = split_reference(ref)
    return num(rmin), num(rmax)


def nutrition_score(text: str) -> dict[str, Any]:
    t = (text or "").lower()
    negatives = {
        "Schweinefleisch": ["schwein", "bratwurst", "salami", "speck", "schinken"],
        "Weizen/Gluten": ["hörnli", "hoernli", "weizen", "pasta", "brot", "pizza", "teigwaren"],
        "Industriezucker": ["torte", "kuchen", "süss", "suess", "zucker", "dessert", "glace"],
        "Histamin": ["alter käse", "kaese", "rotwein", "salami", "ferment", "bier", "wein"],
        "Nachtschatten": ["tomate", "aubergine", "kartoffel", "paprika", "peperoni"],
        "Gesättigte Fette": ["butter", "bratwurst", "kokos", "rahm", "sahne", "speck"],
        "Alkohol": ["alkohol", "bier", "wein", "panaché", "panache"],
    }
    positives = {
        "Fisch/Omega-3": ["lachs", "sardine", "makrele", "forelle", "fisch", "algenöl", "algenoel"],
        "Ballaststoffe": ["hafer", "linsen", "bohnen", "gemüse", "gemuese", "salat", "beeren"],
        "Protein gut": ["skyr", "quark", "huhn", "poulet", "ei", "tofu", "fisch"],
    }
    neg_hits = sorted({label for label, words in negatives.items() if any(w in t for w in words)})
    pos_hits = sorted({label for label, words in positives.items() if any(w in t for w in words)})
    score = max(0, min(100, 85 + 5 * len(pos_hits) - 16 * len(neg_hits)))
    return {"score": score, "neg_hits": neg_hits, "pos_hits": pos_hits, "histamine": 100 if "Histamin" in neg_hits else 0, "satfat": 100 if "Gesättigte Fette" in neg_hits else 0, "sugar": 100 if "Industriezucker" in neg_hits else 0, "gluten": int("Weizen/Gluten" in neg_hits), "pork": int("Schweinefleisch" in neg_hits), "nightshade": int("Nachtschatten" in neg_hits), "alcohol": int("Alkohol" in neg_hits), "fiber": 70 if "Ballaststoffe" in pos_hits else 20, "protein": 70 if "Protein gut" in pos_hits else 30, "omega3": 80 if "Fisch/Omega-3" in pos_hits else 10}


def compute_nutrition_features(c: sqlite3.Connection) -> list[sqlite3.Row]:
    by_day: dict[str, list[str]] = defaultdict(list)
    for r in c.execute("SELECT datum,beschreibung,wirkung,notizen FROM ernaehrung ORDER BY datum"):
        by_day[str(r["datum"])[:10]].append(" ".join(str(r[k] or "") for k in r.keys() if k != "datum"))
    for r in c.execute("SELECT datum,kategori,titel,inhalt,wirkung,notizen FROM tagebuch WHERE lower(kategori) LIKE 'ern%' ORDER BY datum"):
        by_day[str(r["datum"])[:10]].append(" ".join(str(r[k] or "") for k in r.keys() if k != "datum"))
    for day, texts in by_day.items():
        s = nutrition_score(" ".join(texts))
        c.execute("""
            INSERT OR REPLACE INTO nutrition_daily_features
            (datum, plan_adherence_score, histamine_score, saturated_fat_score, sugar_score, gluten_flag, pork_flag, nightshade_flag, alcohol_flag, fiber_proxy, protein_proxy, omega3_proxy, positive_hits, negative_hits, source, computed_at)
            VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)
        """, (day, s["score"], s["histamine"], s["satfat"], s["sugar"], s["gluten"], s["pork"], s["nightshade"], s["alcohol"], s["fiber"], s["protein"], s["omega3"], ", ".join(s["pos_hits"]), ", ".join(s["neg_hits"]), "dashboard_v31_keyword_heuristic"))
    c.commit()
    return list(c.execute("SELECT * FROM nutrition_daily_features ORDER BY datum"))


def apple_series(
    metric: str,
    mode: str,
    rows: list[sqlite3.Row],
) -> dict[str, Any] | None:
    # Use canonical aggregation over rows supplied by the dashboard connection.
    # This keeps synthetic/test databases isolated from the production DB path.
    series = canonical_apple_daily(metric, mode, _rows=rows)
    if not series:
        return None
    labels = sorted(series)
    data = [series[d] for d in labels]
    unit = canonical_apple_unit(metric, _rows=rows)
    return {"labels": labels, "data": data, "unit": unit, "count": len(data), "first": labels[0], "last": labels[-1], "latest": data[-1], "avg": round(mean(data), 2), "min": round(min(data), 2), "max": round(max(data), 2)}


def load_doctor_report_meta() -> dict[str, Any] | None:
    p = REPORTS / "arztbericht_drive_link.json"
    if not p.exists():
        return None
    try:
        return json.loads(p.read_text(encoding="utf-8"))
    except Exception:
        return None


def main(output: Path | None = None) -> None:
    REPORTS.mkdir(parents=True, exist_ok=True)
    counts = db_counts()
    labs = parse_reference_xlsx(best_reference_xlsx()) if best_reference_xlsx().exists() else []
    dates, _params, matrix, _cats, refs = get_lab_matrix(labs) if labs else ([], [], {}, {}, {})

    c = conn()
    try:
        docs = list(c.execute("SELECT id,datei_name,dateipfad,kategorie,status,review_status,upload_datum,drive_web_url,local_original_path,length(COALESCE(extrahierte_inhalte,'')) text_len FROM dokumente ORDER BY id DESC LIMIT 25"))
        lab_docs = list(c.execute("SELECT id,datei_name,dateipfad,kategorie,status,drive_web_url,local_original_path FROM dokumente WHERE upper(COALESCE(kategorie,'')) LIKE '%LABOR%' ORDER BY id DESC LIMIT 40"))
        events = list(c.execute("""
            SELECT date,category,parameter,value,notes,source FROM health_events
            WHERE date IS NOT NULL AND date(date) IS NOT NULL AND upper(COALESCE(category,'')) NOT IN ('LABOR','PROFIL','BEFUNDE')
            ORDER BY date
        """))
        symptoms = list(c.execute("SELECT datum,kategori,symptom,schwergrad,notizen FROM symptome WHERE date(datum) IS NOT NULL ORDER BY datum"))
        symptom_quick_rows = list(c.execute("SELECT id,datum,symptom,schwergrad FROM symptom_log WHERE kontext='daily_quick_score' ORDER BY id"))
        periods = list(c.execute("SELECT start_date,end_date,event_type,label,severity,location,notes FROM health_event_periods ORDER BY start_date DESC LIMIT 50"))
        # Legacy keyword scoring is intentionally not regenerated: incomplete
        # food logs must never become implicit non-exposures or plan adherence.
        nutrition_v2_daily = list(c.execute("SELECT * FROM nutrition_daily_summary_v2 ORDER BY datum"))
        nutrition_v2_meals = list(c.execute("SELECT * FROM nutrition_meal_summary ORDER BY datum, CASE meal WHEN 'breakfast' THEN 1 WHEN 'lunch' THEN 2 WHEN 'dinner' THEN 3 WHEN 'snack' THEN 4 ELSE 9 END"))
        nutrition_v2_items = list(c.execute("""
            SELECT i.datum,i.meal,i.name,i.amount,i.kcal,i.protein_g,i.carb_g,i.fat_g,
                   h.traffic_light,h.canonical_food,h.sighi_score,h.tags,h.confidence
            FROM nutrition_items i LEFT JOIN nutrition_histamine_scores h ON h.item_id=i.id
            WHERE i.source='yazio_api'
            ORDER BY i.datum, CASE i.meal WHEN 'breakfast' THEN 1 WHEN 'lunch' THEN 2 WHEN 'dinner' THEN 3 WHEN 'snack' THEN 4 ELSE 9 END, i.id
        """))
        medication_admins = list(c.execute("SELECT datum,medication_name,dose,route,event_type,scheduled_next_date,notes FROM medication_administrations ORDER BY datum DESC LIMIT 50"))
        administered_medications = [
            row
            for row in medication_admins
            if is_administered_event(row["event_type"])
        ]
        nutrition_review = list(c.execute("SELECT example_name,occurrence_count,first_seen,last_seen,reason FROM nutrition_review_queue WHERE status='open' ORDER BY occurrence_count DESC,last_seen DESC LIMIT 25"))
        if c.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='multimodal_correlation_results'").fetchone():
            multimodal_correlations = list(c.execute("""
                SELECT predictor,target,lag_days,medication_phase,n,eligible_target_days,
                       expected_target_days,target_coverage,rho,q_value,status,interpretation
                FROM multimodal_correlation_results
                ORDER BY CASE status WHEN 'computed' THEN 0 ELSE 1 END,
                         COALESCE(q_value, 2), predictor, lag_days, medication_phase
                LIMIT 100
            """))
        else:
            multimodal_correlations = []
        personal_tolerances = list(c.execute("SELECT canonical_food,personal_status,evidence_level,notes,updated_at FROM personal_food_tolerance ORDER BY CASE personal_status WHEN 'problematic' THEN 1 WHEN 'unclear' THEN 2 WHEN 'safe' THEN 3 ELSE 4 END, canonical_food"))
        apple_raw_by_metric: dict[str, list[sqlite3.Row]] = defaultdict(list)
        for row in c.execute(
            """SELECT id,metric,value,unit,start_date,end_date,source_name,file_name,file_hash
               FROM apple_health_records
               WHERE value IS NOT NULL AND start_date IS NOT NULL
               ORDER BY metric,start_date,end_date,source_name,id"""
        ):
            apple_raw_by_metric[str(row["metric"])].append(row)
        apple = {
            metric: apple_series(metric, mode, apple_raw_by_metric.get(metric, []))
            for metric, _label, mode in APPLE_CHARTS
        }
    finally:
        c.close()

    key_params = [
        "C-Reaktives Protein (CRP)", "Blutsenkungsreaktion miniiSED", "Leukozyten", "Neutrophile", "Lymphozyten", "Monozythen", "Thrombozyten", "Ferritin", "Fibrinogen",
        "D-Dimer", "Faktor VIII", "Cholesterin gesamt", "LDL-Cholesterin", "HDL-Cholesterin", "Triglyceride", "Homocystein",
        "Kreatinin", "eGFR (Niere)", "Albumin/Kreatinin", "Protein", "Blut", "Erythrozyten",
        "ALAT (GPT)", "ASAT (GOT)", "GGT", "Vitamin D (25-OH)", "Vitamin B12 (Active)", "Folsäure", "HLA-B51"
    ]
    event_by_date: dict[str, list[str]] = defaultdict(list)
    for r in events:
        event_by_date[str(r["date"])[:10]].append(f"{r['category']}: {r['parameter']} {r['value'] or ''}".strip())
    for r in symptoms:
        event_by_date[str(r["datum"])[:10]].append(f"Symptom: {r['symptom']} ({r['schwergrad'] or 'n/a'})")

    charts = []
    for p in key_params:
        vals = []
        for d in dates:
            v = matrix.get((p, d))
            if v and num(v.value) is not None:
                vals.append((d, num(v.value)))
        if not vals:
            continue
        labels = [d for d, _ in vals]
        data = [v for _, v in vals]
        event_points = [{"x": d, "label": "; ".join(event_by_date[d])[:180]} for d in labels if d in event_by_date]
        rmin, rmax = ref_nums(refs.get(p))
        cid = re.sub(r"\W+", "_", p)
        charts.append(f"""
<section class='chart-card'><h3>{html.escape(p)}</h3><div class='muted'>Referenz: {html.escape(refs.get(p, 'n/a'))}</div><canvas id='{cid}' height='120'></canvas></section>
<script>makeLabChart('{cid}', {json.dumps(p)}, {json.dumps(labels)}, {json.dumps(data)}, {json.dumps(rmin)}, {json.dumps(rmax)}, {json.dumps(event_points, ensure_ascii=False)});</script>
""")

    # Full lab matrix grouped by physician-style categories; includes every parsed value from the reference XLSX.
    category_order = [
        "Entzündung", "Hämatologie", "Blutstatus Leuk", "Blutbild automatisch absolut",
        "Gerinnung", "Spezielle Gerinnung", "Immunologie / Infektion", "Auto-Antikörper gegen",
        "Virale Hepatitiden A,B,C,D,E", "Blutfette & Stoffwechsel", "Leber & Niere",
        "Elektrolyte & Vitamine", "Proteine", "Schilddrüse & Hormone", "Urin", "Sonstiges"
    ]
    params_by_cat: dict[str, list[str]] = defaultdict(list)
    for p_name in _params:
        params_by_cat[_cats.get(p_name, "Sonstiges")].append(p_name)
    lab_category_sections = []
    for cat in category_order + sorted(set(params_by_cat) - set(category_order)):
        plist = params_by_cat.get(cat, [])
        if not plist:
            continue
        rows = []
        for p_name in plist:
            vals = []
            latest = ""
            for d in dates:
                v = matrix.get((p_name, d))
                cell = html.escape(v.value) if v else ""
                vals.append(f"<td>{cell}</td>")
                if v:
                    latest = f"{html.escape(v.value)} <span class='muted'>{html.escape(d)}</span>"
            rows.append(f"<tr><td><b>{html.escape(p_name)}</b><div class='muted'>Latest: {latest or 'n/a'}</div></td><td>{html.escape(refs.get(p_name, ''))}</td>{''.join(vals)}</tr>")
        lab_category_sections.append(f"""
        <details class='section-block lab-category search-section' data-search='{html.escape(cat + ' ' + ' '.join(plist))}'>
          <summary>{html.escape(cat)} <span class='muted'>({len(plist)} Parameter)</span></summary>
          <div class='section-body'><table class='lab-matrix'><tr><th>Parameter</th><th>Referenz</th>{''.join(f'<th>{html.escape(d)}</th>' for d in dates)}</tr>{''.join(rows)}</table></div>
        </details>
        """)
    all_lab_category_html = "".join(lab_category_sections) or "<p>Keine Laborwerte aus XLSX verfügbar.</p>"
    lab_param_count = len(_params)
    lab_value_count = len(matrix)

    apple_charts = []
    apple_rows = []
    apple_quality_rows = []
    quality_end = datetime.now(APPLE_LOCAL_TZ).date() - timedelta(days=1)
    quality_start = quality_end - timedelta(days=29)
    for metric, label, mode in APPLE_CHARTS:
        quality = apple_coverage_summary(
            metric,
            start_date=quality_start.isoformat(),
            end_date=quality_end.isoformat(),
            mode=mode,
            _rows=apple_raw_by_metric.get(metric, []),
        )
        coverage = float(quality["coverage_ratio"])
        coverage_label = "vollständig" if coverage >= .8 else ("teilweise" if coverage >= .5 else "lückenhaft")
        apple_quality_rows.append(
            f"<tr><td>{html.escape(label)}</td><td>{quality['observed_days']} / {quality['expected_observations']}</td>"
            f"<td>{coverage * 100:.0f}% ({coverage_label})</td><td>{quality['coarse_fallback_days']}</td>"
            f"<td>{quality['days_with_source_conflict']}</td><td>{quality['deduplicated_records']}</td></tr>"
        )
        s = apple.get(metric)
        if not s:
            continue
        cid = "apple_" + re.sub(r"\W+", "_", metric)
        apple_charts.append(f"""
<section class='chart-card'><h3>{html.escape(label)}</h3><div class='muted'>Apple Health Analytics v{APPLE_ANALYTICS_VERSION} · Einheit: {html.escape(s['unit'])}; Zeitraum: {s['first']} bis {s['last']}; Aggregation: {'Summe' if mode == 'sum' else ('letzter Wert' if mode == 'last' else 'Durchschnitt')} pro lokalem Tag</div><canvas id='{cid}' height='110'></canvas></section>
<script>makeLineChart('{cid}', {json.dumps(label)}, {json.dumps(s['labels'])}, {json.dumps(s['data'])}, {json.dumps(s['unit'])});</script>
""")
        apple_rows.append(f"<tr><td>{html.escape(label)}</td><td>{s['first']} bis {s['last']}</td><td>{s['count']}</td><td>{s['latest']} {html.escape(s['unit'])}</td><td>{s['avg']}</td><td>{s['min']}</td><td>{s['max']}</td></tr>")

    cards = "".join(f"<div class='card'><div class='num'>{html.escape(str(v))}</div><div>{html.escape(k)}</div></div>" for k, v in counts.items())
    doc_rows = "".join(f"<tr><td>{r['id']}</td><td>{link_doc(r)}</td><td>{html.escape(str(r['kategorie']))}</td><td>{html.escape(str(r['status']))}</td><td>{html.escape(str(r['review_status'] or ''))}</td><td>{r['text_len']}</td></tr>" for r in docs)
    lab_doc_rows = "".join(f"<tr><td>{r['id']}</td><td>{link_doc(r)}</td><td>{html.escape(str(r['status']))}</td></tr>" for r in lab_docs)
    event_rows = "".join(f"<tr><td>{html.escape(str(r['date']))}</td><td>{html.escape(str(r['category']))}</td><td>{html.escape(str(r['parameter']))}</td><td>{html.escape(str(r['value'] or r['notes'] or ''))}</td></tr>" for r in events[-30:])
    symptom_rows = "".join(f"<tr><td>{html.escape(str(r['datum']))}</td><td>Symptom</td><td>{html.escape(str(r['symptom']))}</td><td>{html.escape(str(r['schwergrad'] or '') + ' ' + str(r['notizen'] or ''))}</td></tr>" for r in symptoms[-30:])
    period_rows = "".join(f"<tr><td>{html.escape(str(r['start_date']))}</td><td>{html.escape(str(r['end_date'] or 'offen'))}</td><td>{html.escape(str(r['event_type']))}</td><td>{html.escape(str(r['label'] or ''))}</td><td>{html.escape(str(r['severity'] or ''))}</td><td>{html.escape(str(r['notes'] or ''))}</td></tr>" for r in periods) or "<tr><td colspan='6'>Noch keine Zeitraum-Events erfasst. Beispiel: Aphte von Start- bis Enddatum.</td></tr>"


    light_icon = {"green":"🟢", "yellow":"🟡", "orange":"🟠", "red":"🔴", "unknown":"⚫"}
    meal_name = {"breakfast":"Frühstück", "lunch":"Mittagessen", "dinner":"Nachtessen", "snack":"Snacks", "unknown":"Unbekannt"}
    nutrient_labels = {
        "nutrient.sugar": "Zucker", "nutrient.fiber": "Ballaststoffe", "nutrient.salt": "Salz",
        "nutrient.sodium": "Natrium", "nutrient.saturated": "gesättigte Fettsäuren",
        "nutrient.protein": "Protein", "nutrient.carb": "Kohlenhydrate", "nutrient.fat": "Fett", "energy.energy": "Energie"
    }
    quick_dimensions = ['Aphthen/Mundulzera','GI/Darm','Müdigkeit/Fatigue','Haut','Augen','Gelenke','Vaskulär/Thrombose-Warnzeichen']
    severity_exact = {
        'keine (0)': 0, 'none (0)': 0, '0': 0,
        'leicht (1)': 1, 'mild (1)': 1, '1': 1,
        'mittel (2)': 2, 'moderat (2)': 2, '2': 2,
        'schwer (3)': 3, 'stark (3)': 3, 'hoch (3)': 3, '3': 3,
    }
    quick_by_day: dict[str, dict[str, list[int | None]]] = defaultdict(lambda: defaultdict(list))
    for r in symptom_quick_rows:
        dimension = str(r["symptom"])
        if dimension in quick_dimensions:
            score = severity_exact.get(str(r["schwergrad"] or "").casefold().strip())
            quick_by_day[str(r["datum"])[:10]][dimension].append(score)
    symptom_by_day = {
        day: sum(values[0] for values in dimensions.values() if values[0] is not None)
        for day, dimensions in quick_by_day.items()
        if set(dimensions) == set(quick_dimensions)
        and all(len(values) == 1 and values[0] is not None for values in dimensions.values())
    }
    nutrition_rows_by_day = {str(r["datum"]): r for r in nutrition_v2_daily}
    nutrition_v2_labels = calendar_axis(nutrition_rows_by_day)
    nutrition_v2_histamine = []
    for day in nutrition_v2_labels:
        row = nutrition_rows_by_day.get(day)
        value = None
        if (
            row is not None
            and row["histamine_score"] is not None
            and int(row["item_count"] or 0) > 0
            and int(row["histamine_unknown_count"] or 0) == 0
        ):
            value = float(row["histamine_score"])
        nutrition_v2_histamine.append(value)
    nutrition_v2_symptom = [symptom_by_day.get(day) for day in nutrition_v2_labels]

    by_day_meal: dict[tuple[str, str], list[sqlite3.Row]] = defaultdict(list)
    for r in nutrition_v2_items:
        by_day_meal[(str(r["datum"]), str(r["meal"]))].append(r)
    meal_summary_by_key = {(str(r["datum"]), str(r["meal"])): r for r in nutrition_v2_meals}
    recent_days = [r["datum"] for r in nutrition_v2_daily[-30:]][::-1]
    overview_cards = []
    for idx, day in enumerate(recent_days):
        day_sum = next((r for r in nutrition_v2_daily if r["datum"] == day), None)
        if not day_sum:
            continue
        label, histamine_load = complete_histamine(day_sum)
        summary_line = (
            f"<summary class='day-summary'>"
            f"<span class='day-date'>{html.escape(day)}</span>"
            f"<span class='pill {html.escape(label)}'>{light_icon.get(label,'⚫')} Histamin {html.escape(label)}</span>"
            f"<span class='day-kcal'>{format_measurement(day_sum['kcal'], 0, ' kcal')}</span>"
            f"<span class='day-load'>Load {histamine_load}</span>"
            f"<span class='day-items'>{format_measurement(day_sum['item_count'], 0, ' Items')}</span>"
            f"</summary>"
        )
        day_macro = (
            f"<div class='day-macro'>Protein {format_measurement(day_sum['protein_g'], 1, ' g')} · "
            f"KH {format_measurement(day_sum['carb_g'], 1, ' g')} · Fett {format_measurement(day_sum['fat_g'], 1, ' g')} · "
            f"Unbekannt {format_measurement(day_sum['histamine_unknown_count'], 0)}</div>"
        )
        meals_html = []
        for m in ["breakfast", "lunch", "dinner", "snack", "unknown"]:
            items_for = by_day_meal.get((day, m), [])
            if not items_for:
                continue
            ms = meal_summary_by_key.get((day, m))
            meal_label, _meal_load = complete_histamine(ms)
            top_nutrients = []
            if ms and ms["nutrient_json"]:
                try:
                    nj = json.loads(ms["nutrient_json"])
                    for k in ["nutrient.sugar", "nutrient.fiber", "nutrient.salt", "nutrient.saturated"]:
                        if k in nj and nj[k] is not None:
                            top_nutrients.append(f"{nutrient_labels.get(k,k)} {format_measurement(nj[k], 1, ' g')}")
                except Exception:
                    pass
            item_cards = "".join(
                f"<div class='food-row {html.escape(item_histamine_label(r))}'>"
                f"<span class='food-h'>{light_icon.get(item_histamine_label(r),'⚫')}</span>"
                f"<span class='food-name'>{html.escape(str(r['name']))}<small>{html.escape(str(r['canonical_food'] or 'unbekannt'))}</small></span>"
                f"<span class='food-amount'>{format_measurement(r['amount'], 0, ' g')}</span>"
                f"<span class='food-kcal'>{format_measurement(r['kcal'], 0, ' kcal')}</span>"
                f"<span class='food-macro'>P {format_measurement(r['protein_g'])} · KH {format_measurement(r['carb_g'])} · F {format_measurement(r['fat_g'])}</span>"
                f"</div>"
                for r in items_for
            )
            meals_html.append(f"""
            <section class='meal-card'><div class='meal-head'><h4>{meal_name.get(m,m)}</h4><span class='pill {html.escape(meal_label)}'>{light_icon.get(meal_label,'⚫')} {html.escape(meal_label)}</span></div>
            <div class='macroline'><b>{format_measurement(ms['kcal'] if ms else None, 0, ' kcal')}</b> · Protein {format_measurement(ms['protein_g'] if ms else None, 1, ' g')} · KH {format_measurement(ms['carb_g'] if ms else None, 1, ' g')} · Fett {format_measurement(ms['fat_g'] if ms else None, 1, ' g')}</div>
            <div class='muted'>{html.escape(' · '.join(top_nutrients) if top_nutrients else 'Details abhängig von YAZIO-Produktdaten')}</div>
            <div class='food-list'>{item_cards}</div></section>
            """)
        open_attr = ""
        overview_cards.append(f"<details class='day-card'{open_attr}>{summary_line}<div class='day-detail'>{day_macro}{''.join(meals_html)}</div></details>")
    meal_explorer_html = "".join(overview_cards) or "<section class='card'>Noch keine Mahlzeiten importiert.</section>"

    medication_rows = "".join(f"<tr><td>{html.escape(str(r['datum']))}</td><td>{html.escape(str(r['medication_name']))}</td><td>{html.escape(str(r['event_type'] or ''))}</td><td>{html.escape(str(r['dose'] or ''))}</td><td>{html.escape(str(r['scheduled_next_date'] or ''))}</td><td>{html.escape(str(r['notes'] or ''))}</td></tr>" for r in medication_admins) or "<tr><td colspan='6'>Noch keine Medikamentengaben erfasst.</td></tr>"

    phase_label_v2 = {
        "baseline_pre_treatment": "Baseline vor Behandlung",
        "early_treatment": "Frühe Behandlungsphase",
        "stable_treatment": "Stabile Behandlungsphase",
        "unknown": "Behandlungsphase unbekannt",
    }
    corr_parts = []
    for r in multimodal_correlations:
        rho = "" if r["rho"] is None else f"{float(r['rho']):.2f}"
        q_value = "" if r["q_value"] is None else f"{float(r['q_value']):.3f}"
        status_labels = {
            "computed": "berechnet",
            "insufficient_n": "unzureichende Paarzahl/Varianz",
            "insufficient_coverage": "Zielabdeckung unter 60 %",
        }
        status = status_labels.get(str(r["status"]), "nicht auswertbar")
        corr_parts.append(
            f"<tr><td>{html.escape(str(r['predictor']))}</td>"
            f"<td>{html.escape(phase_label_v2.get(str(r['medication_phase']), str(r['medication_phase'])))}</td>"
            f"<td>+{r['lag_days']} Tage</td><td>{r['n']} / {r['eligible_target_days']} / {r['expected_target_days']}</td>"
            f"<td>{float(r['target_coverage'] or 0):.0%}</td>"
            f"<td>{rho}</td><td>{q_value}</td><td>{html.escape(status)}</td>"
            f"<td>{html.escape(str(r['interpretation']))}</td></tr>"
        )
    correlation_rows = "".join(corr_parts) or "<tr><td colspan='9'>Noch keine ausreichend strukturierten vollständigen Datenpaare. Fehlende Symptomtage werden nicht als symptomfrei interpretiert.</td></tr>"
    review_rows = "".join(
        f"<tr><td>{html.escape(str(r['example_name']))}</td><td>{r['occurrence_count']}</td><td>{html.escape(str(r['first_seen']))}</td><td>{html.escape(str(r['last_seen']))}</td><td>{html.escape(str(r['reason'] or ''))}</td></tr>"
        for r in nutrition_review
    ) or "<tr><td colspan='5'>Keine offenen unbekannten Produkte.</td></tr>"

    tolerance_label = {
        'safe': 'bisher persönlich dokumentiert vertragen',
        'problematic': 'bisher persönlich als problematisch dokumentiert',
        'unclear': 'persönliche Beobachtung unklar',
        'unknown': 'unbekannt',
    }
    tolerance_rows = "".join(
        f"<tr><td>{html.escape(str(r['canonical_food']))}</td><td>{html.escape(tolerance_label.get(r['personal_status'], 'unbekannt'))}</td><td>{html.escape(str(r['evidence_level'] or ''))}</td><td>{html.escape(str(r['notes'] or ''))}</td><td>{html.escape(str(r['updated_at'] or ''))}</td></tr>"
        for r in personal_tolerances
    ) or "<tr><td colspan='5'>Noch keine persönliche Verträglichkeitsbeobachtung dokumentiert.</td></tr>"

    # --- Extended operational cockpit sections ---
    c_ext = conn()
    doc_view_rows = "".join(
        f"<tr><td>{r['id']}</td><td>{link_doc(r)}</td><td>{html.escape(str(r['document_date'] or r['upload_datum'] or ''))}</td><td>{html.escape(str(r['kategorie'] or ''))}</td><td>{html.escape(str(r['institution'] or ''))}</td><td>{html.escape(str(r['review_status'] or 'nicht_geprueft'))}</td></tr>"
        for r in list(c_ext.execute("SELECT id,datei_name,dateipfad,kategorie,status,review_status,upload_datum,drive_web_url,local_original_path,document_date,institution FROM dokumente ORDER BY COALESCE(document_date,upload_datum) DESC, id DESC LIMIT 30"))
    ) or "<tr><td colspan='6'>Keine Dokumente.</td></tr>"
    doc_counts = list(c_ext.execute("SELECT COALESCE(review_status,'nicht_geprueft') review_status, COALESCE(kategorie,'unkategorisiert') kategorie, COUNT(*) c FROM dokumente GROUP BY review_status,kategorie ORDER BY c DESC LIMIT 20"))
    doc_review_rows = "".join(f"<tr><td>{html.escape(str(r['review_status']))}</td><td>{html.escape(str(r['kategorie']))}</td><td>{r['c']}</td></tr>" for r in doc_counts) or "<tr><td colspan='3'>Keine Dokumente.</td></tr>"

    key_lab_names = ["C-Reaktives Protein (CRP)", "D-Dimer", "Thrombozyten", "Leukozyten", "Ferritin", "Vitamin D (25-OH)"]
    lab_latest = []
    for name in key_lab_names:
        row = c_ext.execute("""SELECT parameter_name,wert,einheit,reference_min,reference_max,
                                      abnahme_datum,befund_datum,validierungsstatus,
                                      verified_against_original,reference_range_source
                               FROM laborwerte WHERE parameter_name=?
                               ORDER BY COALESCE(abnahme_datum,befund_datum,ermittlung_datum) DESC LIMIT 1""", (name,)).fetchone()
        if row:
            lab_latest.append(row)
    lab_latest_cards = "".join(
        f"<div class='info-row'><b>{html.escape(str(r['parameter_name']))}</b><span>{html.escape(str(r['wert']))} {html.escape(str(r['einheit'] or ''))}</span><small>{html.escape(str(r['abnahme_datum'] or r['befund_datum'] or ''))} · {html.escape(str(r['validierungsstatus'] or ''))}</small></div>"
        for r in lab_latest
    ) or "<div class='info-row'>Keine Schlüssellabore gefunden.</div>"

    appointment_docs = list(c_ext.execute("SELECT id,datei_name,dateipfad,kategorie,status,review_status,upload_datum,drive_web_url,local_original_path,document_date,institution FROM dokumente WHERE review_status IN ('arzttermin','relevant') ORDER BY COALESCE(document_date,upload_datum) DESC, id DESC LIMIT 12"))
    appointment_doc_rows = "".join(f"<li>{link_doc(r)} <span class='muted'>({html.escape(str(r['kategorie'] or ''))}, {html.escape(str(r['document_date'] or r['upload_datum'] or ''))})</span></li>" for r in appointment_docs) or "<li>Noch keine Dokumente als relevant/Arzttermin markiert.</li>"
    medication_brief_cards = "".join(
        f"<div class='info-row'><b>{html.escape(str(r['medication_name']))}</b><span>{html.escape(str(r['dose'] or ''))}</span><small>{html.escape(str(r['datum']))} · nächste: {html.escape(str(r['scheduled_next_date'] or 'n/a'))}</small></div>"
        for r in administered_medications[:8]
    ) or "<div class='info-row'>Keine verabreichte Medikation erfasst.</div>"
    recent_sym_data = list(c_ext.execute("SELECT datum,symptom,schwergrad,notizen FROM symptom_log ORDER BY datum DESC, id DESC LIMIT 12"))
    recent_sym_cards = "".join(
        f"<div class='info-row'><b>{html.escape(str(r['symptom']))}</b><span>{html.escape(str(r['schwergrad'] or ''))}</span><small>{html.escape(str(r['datum']))} · {html.escape(str(r['notizen'] or ''))}</small></div>"
        for r in recent_sym_data[:8]
    ) or "<div class='info-row'>Noch wenig Quick-Symptomdaten.</div>"

    timeline = []
    for r in administered_medications[:20]:
        timeline.append((str(r['datum'])[:10], '💉 Verabreichte Medikation', f"{r['medication_name']} {r['dose'] or ''}".strip()))
    for r in symptoms[-40:]:
        timeline.append((str(r['datum'])[:10], '🩺 Symptom', f"{r['symptom']} ({r['schwergrad'] or 'n/a'})"))
    for r in events[-60:]:
        timeline.append((str(r['date'])[:10], str(r['category'] or 'Event'), f"{r['parameter'] or ''} {r['value'] or r['notes'] or ''}".strip()))
    for r in nutrition_v2_daily[-20:]:
        nutrition_label, nutrition_load = complete_histamine(r)
        timeline.append((str(r['datum']), '🍽️ Ernährung', f"{format_measurement(r['kcal'], 0, ' kcal')} · Histamin {nutrition_label} · Load {nutrition_load}"))
    for r in lab_latest:
        timeline.append((str(r['abnahme_datum'] or r['befund_datum'] or '')[:10], '🧪 Labor', f"{r['parameter_name']}: {r['wert']} {r['einheit'] or ''}"))
    timeline = sorted([x for x in timeline if x[0]], key=lambda x: x[0], reverse=True)[:80]
    timeline_rows = "".join(f"<tr data-date='{html.escape(d)}'><td>{html.escape(d)}</td><td>{html.escape(cat)}</td><td>{html.escape(txt)}</td></tr>" for d,cat,txt in timeline) or "<tr><td colspan='3'>Keine Timeline-Daten.</td></tr>"

    symptom_days = calendar_axis(quick_by_day, max_days=30)
    symptom_dims = quick_dimensions
    symptom_map = {
        day: {
            dimension: values[0]
            for dimension, values in dimensions.items()
            if len(values) == 1
        }
        for day, dimensions in quick_by_day.items()
    }
    complete_symptom_days = [d for d in symptom_days if d in symptom_by_day]
    symptom_total = sparse_calendar_series(symptom_days, symptom_by_day)
    symptom_datasets = [{"label":"Total","data":symptom_total,"borderColor":"#1f4e78","backgroundColor":"rgba(31,78,120,.08)","tension":.25}]
    baseline = rolling_median_baseline(symptom_days, symptom_by_day)
    symptom_datasets.append({"label":"Persönliche 14-Tage-Baseline (Median)","data":baseline,"borderColor":"#111827","borderDash":[6,4],"pointRadius":0,"tension":0})
    medication_days = {str(row['datum'])[:10] for row in administered_medications if row['datum']}
    event_days = {str(row['date'])[:10] for row in events if row['date']}
    symptom_datasets.append({"label":"Verabreichte Medikation","data":[21 if day in medication_days else None for day in symptom_days],"borderColor":"#0f766e","backgroundColor":"#0f766e","pointStyle":"triangle","pointRadius":7,"showLine":False})
    symptom_datasets.append({"label":"Health Event","data":[20 if day in event_days else None for day in symptom_days],"borderColor":"#dc2626","backgroundColor":"#dc2626","pointStyle":"rectRot","pointRadius":6,"showLine":False})
    colors = ['#ef4444','#f97316','#7c3aed','#16a34a','#0ea5e9','#a16207','#991b1b']
    for dim, col in zip(symptom_dims, colors):
        observed_dimension = {
            day: dimensions.get(dim) for day, dimensions in symptom_map.items()
        }
        symptom_values = sparse_calendar_series(symptom_days, observed_dimension)
        symptom_datasets.append({"label": dim.split('/')[0], "data": symptom_values, "borderColor": col, "backgroundColor": col, "tension": .2})
    symptom_chart = f"<section class='chart-card'><h3>Symptom-Score Verlauf</h3><div class='muted'>0..3 je Dimension; Total nur bei vollständig dokumentierten sieben Dimensionen. Lücken werden nicht als 0 dargestellt.</div><canvas id='symptom_score_chart' height='130'></canvas></section><script>makeMultiLineChart('symptom_score_chart', {json.dumps(symptom_days)}, {json.dumps(symptom_datasets, ensure_ascii=False)});</script>" if symptom_days else "<section class='chart-card'><h3>Symptom-Score Verlauf</h3><p>Noch keine Quick-Symptomdaten.</p></section>"

    exp_rows = (
        list(c_ext.execute("SELECT id,start_date,end_date,phase,challenge_food,hypothesis,notes,status FROM low_histamine_experiments ORDER BY id DESC LIMIT 20"))
        if c_ext.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='low_histamine_experiments'").fetchone()
        else []
    )
    experiment_rows = "".join(f"<tr><td>{r['id']}</td><td>{html.escape(str(r['start_date']))}</td><td>{html.escape(str(r['end_date'] or 'offen'))}</td><td>{html.escape(str(r['phase']))}</td><td>{html.escape(str(r['challenge_food'] or ''))}</td><td>{html.escape(str(r['status']))}</td><td>{html.escape(str(r['hypothesis'] or r['notes'] or ''))}</td></tr>" for r in exp_rows) or "<tr><td colspan='7'>Noch kein dokumentiertes Low-Histamine-Experiment vorhanden.</td></tr>"

    warnings = []
    for r in lab_latest:
        lab_warning = evaluate_lab_warning(r)
        if lab_warning:
            warnings.append(lab_warning)
    unreviewed = c_ext.execute("SELECT COUNT(*) FROM dokumente WHERE COALESCE(review_status,'nicht_geprueft')='nicht_geprueft'").fetchone()[0]
    if unreviewed:
        warnings.append(('yellow','Dokumente ungeprüft', f'{unreviewed} Dokumente haben noch keinen Review-Status.'))
    if len(complete_symptom_days) < 14:
        warnings.append(('yellow','Symptomdaten unzureichend', f'Nur {len(complete_symptom_days)} vollständig dokumentierte Tage; die neue Engine berechnet erst ab 14 vollständigen Paaren und wertet Signale erst ab 30 Paaren als korrigierte Hypothese.'))
    red_recent = c_ext.execute("SELECT COUNT(*) FROM nutrition_daily_summary_v2 WHERE histamine_label='red' AND date(datum) >= date('now','-30 day')").fetchone()[0]
    if red_recent:
        warnings.append(('orange','Hohe Histamin-Tage', f'{red_recent} rote Tage in den letzten 30 Tagen.'))
    warning_rows = "".join(f"<tr><td><span class='pill {cls}'>{html.escape(cls)}</span></td><td>{html.escape(title)}</td><td>{html.escape(text)}</td></tr>" for cls,title,text in warnings) or "<tr><td colspan='3'>Keine automatischen Hinweise erkannt – dies schließt medizinische Risiken nicht aus.</td></tr>"
    warning_cards = "".join(f"<div class='mini-card'><span class='pill {cls}'>{html.escape(cls)}</span><b>{html.escape(title)}</b><small>{html.escape(text)}</small></div>" for cls,title,text in warnings[:4]) or "<div class='mini-card'><span class='pill'>Info</span><b>Keine automatischen Hinweise erkannt</b><small>Dies schließt medizinische Risiken nicht aus.</small></div>"
    latest_nutrition = nutrition_v2_daily[-1] if nutrition_v2_daily else None
    latest_histamine_label, latest_histamine_load = complete_histamine(latest_nutrition)
    latest_nutrition_card = (
        f"<div class='mini-card'><b>Ernährung zuletzt</b><span>{latest_nutrition['datum']}: {format_measurement(latest_nutrition['kcal'], 0, ' kcal')}</span><span class='pill {html.escape(latest_histamine_label)}'>{light_icon.get(latest_histamine_label,'⚫')} Load {latest_histamine_load}</span></div>"
        if latest_nutrition else "<div class='mini-card'><b>Ernährung</b><span>Keine Daten</span></div>"
    )
    next_med = next((r for r in medication_admins if r['scheduled_next_date']), None)
    next_med_card = (
        f"<div class='mini-card'><b>Nächste Medikation</b><span>{html.escape(str(next_med['scheduled_next_date']))}</span><small>{html.escape(str(next_med['medication_name']))} {html.escape(str(next_med['dose'] or ''))}</small></div>"
        if next_med else "<div class='mini-card'><b>Medikation</b><span>Kein nächster Termin erfasst</span></div>"
    )

    today = datetime.now().date().isoformat()
    today_symptom_complete = today in symptom_by_day
    today_nutrition = nutrition_rows_by_day.get(today)
    today_nutrition_complete = bool(
        today_nutrition
        and int(today_nutrition["item_count"] or 0) > 0
        and int(today_nutrition["histamine_unknown_count"] or 0) == 0
    )
    today_actions = "".join([
        f"<a class='mini-card action-card' href='#symptome'><b>Symptom-Check-in</b><span>{'vollständig' if today_symptom_complete else 'offen'}</span><small>Fehlend bleibt unbekannt.</small></a>",
        f"<a class='mini-card action-card' href='#ernaehrung'><b>Ernährung heute</b><span>{'vollständig klassifiziert' if today_nutrition_complete else 'prüfen oder ergänzen'}</span><small>Unbekannte Klassifikationen werden nicht als null gewertet.</small></a>",
        f"<a class='mini-card action-card' href='#dokumente'><b>Dokumenten-Review</b><span>{unreviewed} offen</span><small>Nur Datenqualität; keine medizinische Risikobewertung.</small></a>",
    ])

    stable_apple_days = [series["last"] for series in apple.values() if series]
    latest_stable_apple_day = max(stable_apple_days) if stable_apple_days else None
    freshness_specs = [
        ("Apple Health – stabiler Datentag", latest_stable_apple_day, 2),
        ("Apple Health – Importzeit", c_ext.execute("SELECT MAX(imported_at) FROM apple_health_records").fetchone()[0], 2),
        ("Ernährung – Datentag", c_ext.execute("SELECT MAX(datum) FROM nutrition_daily_summary_v2").fetchone()[0], 2),
        ("Symptom-Quick-Log – Datentag", c_ext.execute("SELECT MAX(datum) FROM symptom_log WHERE kontext='daily_quick_score'").fetchone()[0], 2),
        ("Dokumentimport – Verarbeitungszeit", c_ext.execute("SELECT MAX(COALESCE(verarbeite_datum,upload_datum)) FROM dokumente").fetchone()[0], 30),
    ]
    freshness_rows = []
    for source, latest, expected_days in freshness_specs:
        status, age = source_freshness(latest, today=today, expected_within_days=expected_days)
        css = "green" if status == "aktuell" else "yellow" if status == "prüfen" else "unknown"
        if age is None:
            age_text = "kein gültiger Zeitstempel"
        elif age < 0:
            age_text = f"Zukunftswert ({-age} Tag(e)) – prüfen"
        else:
            age_text = f"vor {age} Tag(en)"
        freshness_rows.append(
            f"<tr><td>{html.escape(source)}</td><td>{html.escape(str(latest or 'unbekannt'))}</td>"
            f"<td><span class='pill {css}'>{status}</span></td><td>{age_text}</td></tr>"
        )
    source_freshness_table = "".join(freshness_rows)

    top_kpi_cards = (
        f"<div class='mini-card'><b>Warnhinweise</b><span class='big'>{len(warnings)}</span><small>einfache Regeln</small></div>"
        f"<div class='mini-card'><b>Dokumente Review</b><span class='big'>{unreviewed}</span><small>nicht geprüft</small></div>"
        f"<div class='mini-card'><b>Symptomtage</b><span class='big'>{len(complete_symptom_days)}</span><small>vollständige Quick-Logs</small></div>"
        f"<div class='mini-card'><b>Laborwerte</b><span class='big'>{lab_param_count}</span><small>{lab_value_count} Einzelwerte</small></div>"
        + latest_nutrition_card + next_med_card
    )
    c_ext.close()

    meta = load_doctor_report_meta()
    pdf_local = REPORTS / "arztbericht_aktuell.pdf"
    pdf_link = "<a class='button' href='/health-report/arztbericht_aktuell.pdf' target='_blank'>PDF öffnen</a>" if pdf_local.exists() else "<span class='muted'>Noch kein PDF generiert.</span>"
    drive_link = ""
    if meta:
        up = meta.get('drive_upload') or {}
        url = up.get('webViewLink') or up.get('file', {}).get('webViewLink') or up.get('webViewUrl')
        if url:
            drive_link = f" <a class='button' href='{html.escape(url)}' target='_blank'>PDF in Google Drive öffnen</a>"

    now = datetime.now().strftime("%Y-%m-%d %H:%M")
    out = output or (REPORTS / "health_dashboard_v4.html")
    html_text = f"""<!doctype html><html lang='de'><head><meta charset='utf-8'><meta name='viewport' content='width=device-width, initial-scale=1, viewport-fit=cover'><title>JARVIS Health Dashboard v4</title><link rel='icon' href='data:,'>
<script src='/health-assets/chart.umd.min.js'></script>
<script>
function makeLabChart(id, label, labels, data, rmin, rmax, events) {{
  const clean = data.filter(v => v !== null); const maxData = Math.max(...clean); const minData = Math.min(...clean);
  const datasets = [{{label: label, data: data, borderColor:'#1f4e78', backgroundColor:'#1f4e78', pointRadius:4, tension:.25}}];
  if (rmin !== null && rmax !== null) {{ datasets.push({{label:'Ref min', data: labels.map(_=>rmin), borderColor:'rgba(34,197,94,.25)', pointRadius:0, borderWidth:1}}); datasets.push({{label:'Referenzbereich', data: labels.map(_=>rmax), borderColor:'rgba(34,197,94,.7)', backgroundColor:'rgba(34,197,94,.12)', pointRadius:0, borderWidth:1, fill:'-1'}}); }}
  else if (rmax !== null) {{ datasets.push({{label:'Ref max '+rmax, data:labels.map(_=>rmax), borderColor:'#22c55e', borderDash:[6,4], pointRadius:0, borderWidth:2}}); }}
  else if (rmin !== null) {{ datasets.push({{label:'Ref min '+rmin, data:labels.map(_=>rmin), borderColor:'#22c55e', borderDash:[6,4], pointRadius:0, borderWidth:2}}); }}
  if (events.length) {{ const eventData = labels.map(d => events.find(e => e.x === d) ? maxData * 1.08 : null); datasets.push({{label:'Health Event', data:eventData, borderColor:'rgba(239,68,68,.0)', backgroundColor:'#ef4444', pointStyle:'triangle', pointRadius:7, showLine:false}}); }}
  new Chart(document.getElementById(id), {{type:'line', data:{{labels:labels, datasets:datasets}}, options:{{responsive:true, maintainAspectRatio:false, interaction:{{mode:'index',intersect:false}}, plugins:{{tooltip:{{callbacks:{{afterBody:(items)=>{{ const lab=items[0]?.label; const ev=events.find(e=>e.x===lab); return ev ? ['Event: '+ev.label] : []; }}}}}}}}, scales:{{y:{{suggestedMin: Math.min(minData, rmin ?? minData)*0.9, suggestedMax: Math.max(maxData, rmax ?? maxData)*1.15}}, x:{{ticks:{{maxRotation:45,minRotation:45, autoSkip:true, maxTicksLimit:18}}}}}} }} }});
}}
function makeLineChart(id, label, labels, data, unit) {{ new Chart(document.getElementById(id), {{type:'line', data:{{labels:labels, datasets:[{{label:label+' ('+unit+')', data:data, borderColor:'#7c3aed', backgroundColor:'rgba(124,58,237,.12)', fill:true, pointRadius:2, tension:.25}}]}}, options:{{responsive:true, maintainAspectRatio:false, interaction:{{mode:'index',intersect:false}}, plugins:{{legend:{{display:true}}}}, scales:{{x:{{ticks:{{maxRotation:45,minRotation:45, autoSkip:true, maxTicksLimit:18}}}}}}}} }}); }}
function makeMultiLineChart(id, labels, datasets) {{ new Chart(document.getElementById(id), {{type:'line', data:{{labels:labels, datasets:datasets}}, options:{{responsive:true, maintainAspectRatio:false, interaction:{{mode:'index',intersect:false}}, scales:{{x:{{ticks:{{autoSkip:true,maxTicksLimit:14}}}}, y:{{beginAtZero:true,suggestedMax:3}}}}}} }}); }}
function makeBarChart(id, labels, data, hits) {{ new Chart(document.getElementById(id), {{type:'bar', data:{{labels:labels, datasets:[{{label:'Plan-Treue %', data:data, backgroundColor:data.map(v=>v>=80?'#22c55e':v>=60?'#f59e0b':'#ef4444')}}]}}, options:{{responsive:true, maintainAspectRatio:false, plugins:{{tooltip:{{callbacks:{{afterLabel:(ctx)=>'Hinweise: '+hits[ctx.dataIndex]}}}}}}, scales:{{y:{{min:0,max:100}},x:{{ticks:{{autoSkip:true,maxTicksLimit:18}}}}}}}} }}); }}
function makeNutritionCorrelationChart(id, labels, histamine, symptoms) {{ new Chart(document.getElementById(id), {{type:'line', data:{{labels:labels, datasets:[{{label:'Histamin-Load', data:histamine, borderColor:'#ef4444', backgroundColor:'rgba(239,68,68,.08)', yAxisID:'y', tension:.25}},{{label:'Symptomscore', data:symptoms, borderColor:'#7c3aed', backgroundColor:'rgba(124,58,237,.08)', yAxisID:'y1', tension:.25}}]}}, options:{{responsive:true, maintainAspectRatio:false, interaction:{{mode:'index',intersect:false}}, scales:{{y:{{type:'linear',position:'left'}},y1:{{type:'linear',position:'right',grid:{{drawOnChartArea:false}}}},x:{{ticks:{{autoSkip:true,maxTicksLimit:20}}}}}}}} }}); }}
function filterDashboard(q) {{
  q=(q||'').toLowerCase().trim();
  document.querySelectorAll('.search-section').forEach(el=>{{
    const text=(el.getAttribute('data-search')||el.innerText||'').toLowerCase();
    const show=!q || text.includes(q);
    el.style.display=show?'':'none';
    if(q && show) {{ let cur=el; while(cur) {{ if(cur.tagName && cur.tagName.toLowerCase()==='details') cur.open=true; cur=cur.parentElement; }} }}
  }});
  document.querySelectorAll('table').forEach(tbl=>{{
    let any=false;
    tbl.querySelectorAll('tr').forEach((tr,idx)=>{{
      const isHead=idx===0 || tr.querySelector('th');
      const match=!q || (tr.innerText||'').toLowerCase().includes(q) || isHead;
      tr.style.display=match?'':'none';
      if(match && !isHead) any=true;
    }});
    if(q && any) {{ let cur=tbl.parentElement; while(cur) {{ if(cur.tagName && cur.tagName.toLowerCase()==='details') cur.open=true; cur=cur.parentElement; }} }}
  }});
}}
function openSection(id) {{ const el=document.getElementById(id); if(!el) return; if(el.tagName.toLowerCase()==='details') el.open=true; el.scrollIntoView({{behavior:window.matchMedia('(prefers-reduced-motion: reduce)').matches?'auto':'smooth',block:'start'}}); }}
function applyPrivacyMode(enabled) {{ document.body.classList.toggle('privacy-active',!!enabled); const b=document.getElementById('privacyToggle'); if(b)b.setAttribute('aria-pressed',enabled?'true':'false'); }}
function applyPrintMode(enabled) {{ document.body.classList.toggle('print-mode',!!enabled); if(enabled)window.print(); }}
function applyContrastMode(enabled) {{ document.body.classList.toggle('high-contrast-mode',!!enabled); const b=document.getElementById('contrastToggle'); if(b)b.setAttribute('aria-pressed',enabled?'true':'false'); }}
function filterTimelineDays(days) {{
 const cutoff=days?new Date(Date.now()-days*86400000):null;
 document.querySelectorAll('#timeline tr[data-date]').forEach(row=>{{ const value=new Date(row.dataset.date+'T00:00:00'); row.hidden=!!cutoff && value<cutoff; }});
 document.querySelectorAll('[data-period]').forEach(button=>button.setAttribute('aria-pressed',String(Number(button.dataset.period||0)===days)));
}}
document.addEventListener('DOMContentLoaded',()=>{{
 const params=new URLSearchParams(window.location.search);
 const search=document.getElementById('dashSearch'); const privacy=document.getElementById('privacyToggle'); const doctor=document.getElementById('doctorMode'); const contrast=document.getElementById('contrastToggle');
 search?.addEventListener('input',event=>filterDashboard(event.target.value));
 privacy?.addEventListener('click',()=>applyPrivacyMode(!document.body.classList.contains('privacy-active')));
 doctor?.addEventListener('click',()=>applyPrintMode(true));
 contrast?.addEventListener('click',()=>applyContrastMode(!document.body.classList.contains('high-contrast-mode')));
 document.querySelectorAll('[data-period]').forEach(button=>button.addEventListener('click',()=>filterTimelineDays(Number(button.dataset.period||0))));
 if(params.get('mode')==='privacy')applyPrivacyMode(true);
 if(params.get('mode')==='doctor'||params.get('mode')==='print')document.body.classList.add('print-mode');
 if(params.get('contrast')==='high')applyContrastMode(true);
}});
</script>
<style>
:root{{--blue:#1f4e78;--bg:#f6f8fb;--text:#1f2937;--muted:#6b7280;--line:#e5e7eb}}
*{{box-sizing:border-box}}body{{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;margin:18px;background:var(--bg);color:var(--text);font-size:15px;line-height:1.35}}a{{color:var(--blue)}}h1{{font-size:1.55rem;margin:.2rem 0}}h2{{font-size:1.25rem;margin:1.25rem 0 .6rem}}.grid{{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px}}.card,.chart-card,.day-card{{background:white;border-radius:14px;padding:14px;box-shadow:0 2px 8px #0001;margin-bottom:14px}}.num{{font-size:26px;font-weight:700;color:var(--blue)}}table{{border-collapse:collapse;width:100%;background:white;margin-bottom:14px;display:block;overflow-x:auto;-webkit-overflow-scrolling:touch}}td,th{{border:1px solid #ddd;padding:6px;vertical-align:top;white-space:nowrap}}code{{white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word}}th{{background:var(--blue);color:white}}canvas{{background:white;border-radius:12px;margin-bottom:12px;width:100%!important;max-height:330px}}.muted{{color:var(--muted);font-size:.9em;margin-bottom:8px}}.warn{{background:#fff7ed;border-left:4px solid #f97316;padding:10px;border-radius:8px}}.button{{display:inline-block;background:var(--blue);color:white!important;text-decoration:none;padding:10px 14px;border-radius:8px;margin:4px 8px 4px 0}}.pill{{display:inline-flex;align-items:center;gap:3px;border-radius:999px;padding:3px 8px;font-size:.82em;font-weight:700;white-space:nowrap}}.green{{background:#dcfce7;color:#166534}}.yellow{{background:#fef9c3;color:#854d0e}}.orange{{background:#ffedd5;color:#9a3412}}.red{{background:#fee2e2;color:#991b1b}}.unknown{{background:#e5e7eb;color:#374151}}.day-list{{display:grid;grid-template-columns:1fr;gap:10px}}.day-card{{padding:0;overflow:hidden}}.day-summary{{display:grid;grid-template-columns:1.25fr auto auto auto auto;gap:8px;align-items:center;cursor:pointer;padding:14px}}.day-summary::-webkit-details-marker{{display:none}}.day-summary:before{{content:'▸';font-size:14px;color:var(--muted)}}.day-card[open]>.day-summary:before{{content:'▾'}}.day-date{{font-weight:800}}.day-kcal{{font-weight:800;color:var(--blue)}}.day-load,.day-items{{color:var(--muted);font-size:.9em}}.day-detail{{border-top:1px solid var(--line);padding:12px 14px}}.day-macro{{font-weight:600;margin-bottom:10px;color:#374151}}.meal-card{{border:1px solid var(--line);border-radius:12px;padding:12px;margin:10px 0;background:#fbfdff}}.meal-head{{display:flex;justify-content:space-between;align-items:center;gap:8px}}.meal-head h4{{margin:0;font-size:1rem}}.macroline{{margin:6px 0 8px;color:#374151}}.food-list{{display:grid;gap:6px}}.food-row{{display:grid;grid-template-columns:24px minmax(0,1fr) 52px 60px;grid-template-areas:'h name amount kcal' '. name macro macro';gap:2px 8px;align-items:center;padding:8px;border:1px solid var(--line);border-radius:10px;background:white}}.food-h{{grid-area:h}}.food-name{{grid-area:name;font-weight:650;min-width:0;overflow-wrap:anywhere}}.food-name small{{display:block;color:var(--muted);font-weight:400}}.food-amount{{grid-area:amount;text-align:right;color:#374151}}.food-kcal{{grid-area:kcal;text-align:right;font-weight:700}}.food-macro{{grid-area:macro;color:var(--muted);font-size:.86em;text-align:right}}.chart-card table,.card table{{font-size:.92em}}.topbar{{position:sticky;top:0;z-index:20;background:rgba(246,248,251,.96);backdrop-filter:blur(8px);padding:8px 0 10px;border-bottom:1px solid var(--line);margin-bottom:12px}}.quicklinks{{display:flex;gap:8px;overflow-x:auto;-webkit-overflow-scrolling:touch;padding:4px 0}}.quicklinks a,.quicklinks button{{white-space:nowrap;border:1px solid var(--line);background:white;color:var(--blue);padding:8px 10px;border-radius:999px;text-decoration:none;font-weight:700}}.searchbox{{width:100%;border:1px solid var(--line);border-radius:12px;padding:11px 12px;font-size:16px;background:white}}.kpi-grid{{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:10px}}.mini-card{{background:white;border:1px solid var(--line);border-radius:14px;padding:12px;display:flex;flex-direction:column;gap:5px;min-height:88px}}.mini-card b{{font-size:.95rem}}.mini-card .big{{font-size:1.8rem;font-weight:800;color:var(--blue)}}.mini-card small{{color:var(--muted)}}.section-block{{background:white;border-radius:14px;box-shadow:0 2px 8px #0001;margin:14px 0;overflow:hidden}}.section-block>summary{{cursor:pointer;list-style:none;padding:16px;font-size:1.12rem;font-weight:800;color:var(--blue);border-bottom:1px solid var(--line)}}.section-block>summary::-webkit-details-marker{{display:none}}.section-block>summary:before{{content:'▸ ';color:var(--muted)}}.section-block[open]>summary:before{{content:'▾ '}}.section-body{{padding:14px}}.priority{{border-left:5px solid var(--blue)}}.legacy-note{{font-size:.85em;color:var(--muted)}}.info-list{{display:grid;gap:8px}}.info-row{{border:1px solid var(--line);border-radius:12px;padding:10px;background:#fff;display:grid;gap:3px}}.info-row b{{overflow-wrap:anywhere}}.info-row span{{font-weight:800;color:var(--blue)}}.info-row small{{color:var(--muted);overflow-wrap:anywhere}}
@media (max-width: 700px){{body{{margin:8px;padding-bottom:76px;font-size:14px}}h1{{font-size:1.3rem}}h2{{font-size:1.1rem}}.card,.chart-card,.day-card{{border-radius:12px;margin-bottom:10px}}.grid{{grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}}.num{{font-size:22px}}.day-summary{{grid-template-columns:18px 1fr auto;grid-template-areas:'arrow date kcal' 'arrow pill load' 'arrow items items';gap:4px 8px;padding:12px}}.day-summary:before{{grid-area:arrow}}.day-date{{grid-area:date}}.day-summary .pill{{grid-area:pill;justify-self:start}}.day-kcal{{grid-area:kcal}}.day-load{{grid-area:load}}.day-items{{grid-area:items}}.day-detail{{padding:10px}}.meal-card{{padding:10px}}.food-row{{grid-template-columns:22px minmax(0,1fr) 54px;grid-template-areas:'h name kcal' '. name amount' '. macro macro';gap:2px 6px}}.food-amount,.food-kcal,.food-macro{{text-align:right}}.chart-card{{overflow:hidden}}table{{font-size:12px;max-width:100%}}td,th{{padding:5px}}canvas{{max-height:260px}}.button{{width:100%;text-align:center}}.top-actions{{position:static}}.bottom-nav{{display:flex}}}}
.skip-link{{position:absolute;left:-9999px;top:8px;background:#111827;color:#fff;padding:10px 14px;z-index:999;border-radius:8px}}.skip-link:focus-visible{{left:8px}}a,button,input,select,textarea,summary{{min-height:44px;touch-action:manipulation}}input,select,textarea{{width:100%;font:inherit;border:1px solid var(--line);border-radius:8px;padding:9px;background:#fff}}label{{display:grid;gap:5px;font-weight:700}}.touch-checkin{{display:grid;gap:12px}}*:focus-visible{{outline:3px solid #2563eb;outline-offset:2px}}.top-actions{{position:sticky;top:0;z-index:40;display:flex;gap:8px;justify-content:flex-end;padding:8px;background:#f6f8fbeF}}.mode-button{{border:1px solid var(--line);background:#fff;border-radius:9px;padding:8px 12px;cursor:pointer}}.bottom-nav{{display:none;position:fixed;left:0;right:0;bottom:0;z-index:50;background:#fff;border-top:1px solid var(--line);gap:2px;padding:5px}}.bottom-nav a{{flex:1;display:grid;place-items:center;text-align:center;text-decoration:none;font-size:12px;padding:4px}}.privacy-active [data-sensitive='1']{{display:none!important}}.print-only{{display:none}}@media (prefers-reduced-motion:reduce){{*,*::before,*::after{{animation-duration:.01ms!important;transition-duration:.01ms!important;scroll-behavior:auto!important}}}}@media (prefers-contrast:more){{:root{{--text:#000;--bg:#fff;--muted:#111;--line:#000}}}}@media print{{.topbar,.top-actions,.bottom-nav,.print-hide{{display:none!important}}body{{margin:0;background:#fff;color:#000;font-size:11pt}}details{{display:block}}details>summary{{font-weight:700}}details>.section-body{{display:block!important}}.print-only{{display:block!important}}.card,.chart-card,.day-card{{box-shadow:none;border:1px solid #777;break-inside:avoid}}a{{color:#000;text-decoration:none}}}}
</style></head><body>
<a class='skip-link' href='#main-content'>Zum Hauptinhalt springen</a>
<header>
  <div class='top-actions print-hide' aria-label='Ansichtsmodi'>
    <button id='privacyToggle' class='mode-button' type='button' aria-pressed='false'>Privacy</button>
    <button id='doctorMode' class='mode-button' type='button'>Arzt / Drucken</button>
    <button id='contrastToggle' class='mode-button' type='button' aria-pressed='false'>Kontrast</button>
  </div>
  <h1>JARVIS Health Dashboard v4</h1><p class='muted'>Generiert: {now}</p>
</header>
<div class='topbar'>
  <input id='dashSearch' class='searchbox' type='search' placeholder='Suche: Labor, PDF, Hyrimoz, CRP, Symptom, Lebensmittel…'>
  <nav class='quicklinks'>
    <a href='#overview'>Übersicht</a><a href='#arzt'>Arzttermin</a><a href='#warnung'>Warnung</a><a href='#ernaehrung'>Ernährung</a><a href='#dokumente'>Dokumente</a><a href='#symptome'>Symptome</a><a href='#timeline'>Timeline</a><a href='#trends'>Trends</a><a href='#system'>System</a>
  </nav>
</div>

<main id='main-content'>
<section id='overview' class='search-section priority' data-search='heute aktionen übersicht status warnung dokumente symptome ernährung medikation arzttermin'>
  <h2>Heute & Aktionen</h2>
  <div class='kpi-grid'>{today_actions}</div>
  <h2>Relevante Übersicht</h2>
  <div class='kpi-grid'>{top_kpi_cards}</div>
  <section class='chart-card'><h3>Quellenfrische</h3><div class='muted'>Frische bewertet ausschließlich den technischen Datenstand, nicht Gesundheit oder medizinisches Risiko.</div><table><tr><th>Quelle</th><th>Letzter Datentag</th><th>Status</th><th>Alter</th></tr>{source_freshness_table}</table></section>
  <div class='chart-card'><h3>Aktuelle Hinweise</h3><div class='kpi-grid'>{warning_cards}</div></div>
</section>

<details id='arzt' class='section-block search-section' data-sensitive='1' open data-search='arzttermin konsulation labor medikation dokumente symptome fragen pdf'>
  <summary>Arzttermin-Modus</summary>
  <div class='section-body'>
    <div class='grid'>
      <div class='card'><h3>Schlüssellabore</h3><div class='info-list'>{lab_latest_cards}</div></div>
      <div class='card'><h3>Medikation</h3><div class='info-list'>{medication_brief_cards}</div></div>
    </div>
    <div class='grid'>
      <div class='card'><h3>Relevante Dokumente</h3><ul>{appointment_doc_rows}</ul></div>
      <div class='card'><h3>Letzte Symptome</h3><div class='info-list'>{recent_sym_cards}</div></div>
    </div>
    <div class='card'><h3>Arztbericht PDF</h3><p>{pdf_link}{drive_link}</p><p class='muted'>Aktualisieren: <code>python3 {BASE}/scripts/generate_doctor_report.py && python3 {BASE}/scripts/health_pipeline.py dashboard</code></p></div>
  </div>
</details>

<details id='warnung' class='section-block search-section' data-sensitive='1' open data-search='warnlogik warnung crp d-dimer dokumente symptome risiko'>
  <summary>Warnlogik & To-dos</summary>
  <div class='section-body'>
    <section class='chart-card'><div class='muted'>Automatische Hinweise unterstützen Datenprüfung und Arztvorbereitung. Keine erkannten Hinweise schließen medizinische Risiken nicht aus und sind keine Diagnose.</div><table><tr><th>Level</th><th>Hinweis</th><th>Details</th></tr>{warning_rows}</table></section>
  </div>
</details>

<details id='ernaehrung' class='section-block search-section' data-sensitive='1' open data-search='ernährung yazio histamin lebensmittel kcal protein kohlenhydrate fett low histamine'>
  <summary>Ernährung & Histamin</summary>
  <div class='section-body'>
    <div class='warn'><b>Hinweis:</b> Histamin-Ampeln nutzen lokale SIGHi-inspirierte Regeln plus Alias-Mapping; rot nur bei hoher Gesamtlast.</div>
    <div class='day-list'>{meal_explorer_html}</div>
    <details class='section-block search-section' data-sensitive='1' data-search='multimodal apple health ernährung labor symptomscore korrelation'><summary>Explorative multimodale Zusammenhänge</summary><div class='section-body'><section class='chart-card'><h3>Histamin-Load und dokumentierte Symptomtage</h3><div class='muted'>Lücken bleiben Lücken: nicht dokumentierte Symptomtage werden nicht als symptomfrei dargestellt.</div><canvas id='nutrition_correlation' height='120'></canvas></section><script>makeNutritionCorrelationChart('nutrition_correlation', {json.dumps(nutrition_v2_labels)}, {json.dumps(nutrition_v2_histamine)}, {json.dumps(nutrition_v2_symptom)});</script><section class='chart-card'><h3>Vollständige Datenpaare nach Behandlungsphase</h3><div class='warn'><b>Nur Hypothesen:</b> Rangkorrelationen sind vollständig fallbasiert, phasenstratifiziert und mehrfachtestkorrigiert. Sie zeigen keine Ursache, Diagnose, Therapieempfehlung oder Entwarnung.</div><div class='table-scroll'><table><tr><th>Prädiktor</th><th>Phase</th><th>Lag</th><th>n / beobachtet / erwartet</th><th>Ziel-Coverage</th><th>ρ</th><th>q</th><th>Status</th><th>Interpretation</th></tr>{correlation_rows}</table></div></section></div></details>
    <details class='section-block search-section' data-sensitive='1' data-search='personal tolerance lebensmittel'><summary>Personal Tolerance</summary><div class='section-body'><div class='muted'>Rein persönliche, zeitgebundene Dokumentation; keine Lebensmittel-Sicherheitsklassifikation oder allgemeine Verträglichkeitsaussage. Datenvollständigkeit und Beobachtungszeitraum können begrenzt sein. Automatische Legacy-Safe-/Trigger-Kandidaten bleiben deaktiviert.</div><section class='chart-card'><h3>Personal Tolerance Layer</h3><table><tr><th>Lebensmittelgruppe</th><th>Status</th><th>Evidenz</th><th>Notiz</th><th>Aktualisiert</th></tr>{tolerance_rows}</table></section></div></details>
    <details class='section-block search-section' data-sensitive='1' data-search='low histamine experiment baseline challenge'><summary>Low-Histamine Experiment Tracker</summary><div class='section-body'><div class='warn'><b>Dokumentationsansicht:</b> Baseline-, Beobachtungs- und Challenge-Phasen sind rein prospektive persönliche Dokumentation. Sie prüfen keine Kausalität und sind keine Therapie- oder Challenge-Empfehlung. Fehlende Symptom- oder Ernährungsdaten bleiben unbekannt. Potenziell riskante Expositionen nur nach ärztlicher Abstimmung.</div><table><tr><th>ID</th><th>Start</th><th>Ende</th><th>Phase</th><th>Dokumentierte Exposition</th><th>Status</th><th>Hypothese/Notiz</th></tr>{experiment_rows}</table></div></details>
    <details class='section-block search-section' data-sensitive='1' data-search='mapping review queue unbekannt'><summary>Mapping Review Queue</summary><div class='section-body'><table><tr><th>Beispielprodukt</th><th>Vorkommen</th><th>Erstmals</th><th>Zuletzt</th><th>Grund</th></tr>{review_rows}</table></div></details>
  </div>
</details>

<details id='dokumente' class='section-block search-section' data-sensitive='1' data-search='dokumente pdf berichte labor arztbrief inbox review'>
  <summary>Dokumente & PDFs</summary>
  <div class='section-body'>
    <section class='chart-card'><h3>Aktuelle Dokumente</h3><div class='muted'>Links öffnen über Tailscale-Route.</div><table><tr><th>ID</th><th>Dokument</th><th>Datum</th><th>Kategorie</th><th>Institution</th><th>Review</th></tr>{doc_view_rows}</table></section>
    <section class='chart-card'><h3>Dokumenten-Inbox Review</h3><table><tr><th>Review</th><th>Kategorie</th><th>Anzahl</th></tr>{doc_review_rows}</table><div class='muted'>CLI: <code>python3 {BASE}/scripts/health_doc_review.py ID --status arzttermin|relevant|archiviert</code></div></section>
  </div>
</details>

<details id='symptome' class='section-block search-section' data-sensitive='1' data-search='symptome aphthen gi fatigue haut augen gelenke quick log'>
  <summary>Symptome</summary>
  <div class='section-body'>{symptom_chart}<section class='chart-card'><h3>Daily Symptom Quick Log</h3><div class='muted'>Alle sieben Dimensionen sind Pflicht. Fehlende Angaben werden nicht als 0 gespeichert.</div><form class='touch-checkin' method='post' action='/health-actions/symptom-checkin'><input type='hidden' name='csrf_token' value='__CSRF_TOKEN__'><label>Datum<input type='date' name='date' value='{today}' required></label><div class='grid'>{''.join(f"<label>{html.escape(label)}<select name='{key}' required><option value='' selected>Bitte wählen</option><option value='0'>0 – keine</option><option value='1'>1 – leicht</option><option value='2'>2 – mittel</option><option value='3'>3 – schwer</option></select></label>" for key,label in [('aphthen','Aphthen/Mundulzera'),('gi','GI/Darm'),('fatigue','Müdigkeit/Fatigue'),('skin','Haut'),('eyes','Augen'),('joints','Gelenke'),('vascular','Vaskulär/Thrombose-Warnzeichen')])}</div><label>Notiz optional<textarea name='notes' maxlength='300' rows='3'></textarea></label><button class='button' type='submit'>Vollständigen Tages-Check-in speichern</button></form></section></div>
</details>

<details id='timeline' class='section-block search-section' data-sensitive='1' data-search='timeline zeitraum ereignisse medikation labor symptome ernährung'>
  <summary>Health Timeline</summary>
  <div class='section-body'><div class='actions period-filter' role='group' aria-label='Timeline-Zeitraum'><button class='mode-button' type='button' data-period='7' aria-pressed='false'>7 Tage</button><button class='mode-button' type='button' data-period='30' aria-pressed='false'>30 Tage</button><button class='mode-button' type='button' data-period='90' aria-pressed='false'>90 Tage</button><button class='mode-button' type='button' data-period='0' aria-pressed='true'>Alle</button></div><table><tr><th>Datum</th><th>Typ</th><th>Eintrag</th></tr>{timeline_rows}</table></div>
</details>

<details id='trends' class='section-block search-section' data-sensitive='1' data-search='trends apple health labor crp d-dimer hrv schlaf puls spo2'>
  <summary>Trends, Labor & Apple Health</summary>
  <div class='section-body'>
    <details class='section-block'><summary>Apple Health Trends</summary><div class='section-body'><div class='muted'>Analytics v{APPLE_ANALYTICS_VERSION}: lokale Zeitzone Zürich, deduplizierte Exporte, direkte Tagesdaten vor gruppierten Fallback-Daten und konservative Auswahl einer Quelle pro Metrik/Tag.</div><table><tr><th>Metrik</th><th>Zeitraum</th><th>Tage</th><th>letzter Wert</th><th>Ø</th><th>Min</th><th>Max</th></tr>{''.join(apple_rows)}</table><details class='section-block'><summary>Datenqualität der letzten 30 abgeschlossenen Tage</summary><div class='section-body'><table><tr><th>Metrik</th><th>beobachtet / erwartet</th><th>Abdeckung</th><th>Fallback-Tage</th><th>Quellkonflikt-Tage</th><th>deduplizierte Records</th></tr>{''.join(apple_quality_rows)}</table><p class='muted'>Erwartete Kadenz: kontinuierliche Metriken täglich, Körpermessungen wöchentlich. Diese Einstufung bewertet ausschließlich Datenabdeckung und Importqualität, nicht Gesundheit oder medizinisches Risiko.</p></div></details>{''.join(apple_charts) or '<p>Keine Apple-Health-Daten verfügbar.</p>'}</div></details>
    <details class='section-block' open><summary>Alle Laborwerte nach Thema</summary><div class='section-body'><div class='warn'><b>Provenienz-Regel:</b> Kanonische Referenz ist der eingescannte Original-Laborbericht des jeweiligen Untersuches — auch für Referenzbereiche. Die XLSX ist nur sekundäre Arbeitsübersicht und kann Fehler enthalten.</div><p class='muted'>{lab_param_count} Parameter · {lab_value_count} Einzelwerte aus der XLSX-Arbeitsmatrix. Kategorien sind aufklappbar und suchbar; klinische Verifikation immer gegen Originalscan.</p>{all_lab_category_html}</div></details>
    <details class='section-block'><summary>Labortrend-Charts Auswahl</summary><div class='section-body'>{''.join(charts) or '<p>Keine Labortrends verfügbar.</p>'}</div></details>
  </div>
</details>

<details id='system' class='section-block search-section' data-sensitive='1' data-search='system status archiv legacy dokumente labor events'>
  <summary>System, Archiv & Legacy-Listen</summary>
  <div class='section-body'>
    <p class='legacy-note'>Technische/alte Listen — nützlich für Debugging, aber nicht primär für die tägliche Ansicht.</p>
    <details class='section-block'><summary>Systemstatus</summary><div class='section-body'><div class='grid'>{cards}</div></div></details>
    <details class='section-block'><summary>Medikations-Timeline Rohdaten</summary><div class='section-body'><table><tr><th>Datum</th><th>Medikament</th><th>Ereignis</th><th>Dosis</th><th>Nächste geplant</th><th>Notiz</th></tr>{medication_rows}</table></div></details>
    <details class='section-block'><summary>Health Event Periods</summary><div class='section-body'><table><tr><th>Start</th><th>Ende</th><th>Typ</th><th>Label</th><th>Schweregrad</th><th>Notizen</th></tr>{period_rows}</table></div></details>
    <details class='section-block'><summary>Laborberichte / Original-PDFs</summary><div class='section-body'><table><tr><th>ID</th><th>Original</th><th>Status</th></tr>{lab_doc_rows}</table></div></details>
    <details class='section-block'><summary>Klinische Events, Medikamente & Symptome</summary><div class='section-body'><table><tr><th>Datum</th><th>Kategorie</th><th>Parameter/Symptom</th><th>Wert/Notiz</th></tr>{event_rows}{symptom_rows}</table></div></details>
    <details class='section-block'><summary>Letzte Dokumente Legacy</summary><div class='section-body'><table><tr><th>ID</th><th>Datei</th><th>Kategorie</th><th>Status</th><th>Review</th><th>Volltext-Zeichen</th></tr>{doc_rows}</table></div></details>
  </div>
</details>
<section class='print-only' aria-label='Arzt- und Druckhinweise'>
  <h2>Evidenz- und Provenienzhinweise</h2>
  <p>Diese Ansicht unterstützt Dokumentation und Arztvorbereitung. Sie ist keine Diagnose, Therapieempfehlung oder Entwarnung.</p>
  <p>Laborangaben müssen gegen den validierten Originalbericht geprüft werden. Fehlende Daten bedeuten unbekannt und nicht symptomfrei oder unauffällig.</p>
  <p>Explorative Zusammenhänge sind hypothesengenerierend und belegen keine Ursache.</p>
</section>
</main>
<nav class='bottom-nav print-hide' aria-label='Mobile Schnellnavigation'>
  <a href='#overview'>Heute</a><a href='#symptome'>Symptome</a><a href='#trends'>Trends</a><a href='#arzt'>Arzt</a><a href='#system'>Mehr</a>
</nav>
</body></html>"""
    out.parent.mkdir(parents=True, exist_ok=True)
    temporary_output = out.with_name(f".{out.name}.{os.getpid()}.tmp")
    try:
        temporary_output.write_text(html_text, encoding="utf-8")
        os.replace(temporary_output, out)
    finally:
        temporary_output.unlink(missing_ok=True)
    c = conn()
    c.execute(
        "INSERT INTO report_runs(report_type, output_path, summary) VALUES(?,?,?)",
        ("dashboard_v4", str(out), "Dashboard v4 mit lokaler Chart-Auslieferung generiert"),
    )
    c.commit()
    c.close()
    print(out)

if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="Generate Health Dashboard v4")
    parser.add_argument("--output", type=Path, default=None)
    args = parser.parse_args()
    main(args.output)
