Zuletzt aktualisiert:

Heute-Scharnier / Plan↔Tagebuch — Backend-Vertrag (W4)

Stand 09.06.2026, StratoClaude. Backend zum „Heute-Tab v2" (Task #22, MacClaude- Revier app/ios-native/). Aus der Plan↔Tagebuch-Synthese: der Wochenplan und das Tagebuch fühlten sich überlappend an — W4 verschmilzt sie über ein „Heute"-Scharnier. Drei fork-unabhängige Backend-Sachen (Bus #64), alle additiv, hinter Auth (Session oder X-Internal-Token). Idempotenz-Basis = ADR-42 (s. u. §4).

§1 — Auto-Match: Kochmodus hakt den Plan mit ab (WafSkeptic-T1)

Problem: Kocht man ein geplantes Gericht über den allgemeinen Kochmodus- Einstieg (POST /api/dishes/{id}/log) statt über die Plan-Kachel, blieb der Plan- Eintrag inkonsistent als „noch zu kochen" stehen — obwohl längst gegessen.

Lösung: POST /api/dishes/{id}/log markiert nach dem Tagebuch-Eintrag einen passenden Plan-Eintrag desselben Gerichts am selben Tag automatisch als gegessen:

Bewusste Grenzen: - shared-Pläne bleiben außen vor (Wir-Modus, ADR-28): ein einzelnes dishes/log darf einen geteilten Plan nicht als „für alle gegessen" abhaken — das läuft punktgenau über plan_eat (bucht je 1 Portion in jedes Profil-Tagebuch). - Kein Auto-Match bei Idempotenz (res.idempotent, ADR-42): hängt schon ein Eintrag/Plan dran, wird beim Doppel-Tap/Retry nicht erneut gematcht. - plan_eat selbst löst keinen Auto-Match aus — es ruft intern _log_dish_core (ohne Match) und hakt seinen Plan-Eintrag per plan_id exakt selbst ab. Sonst könnte ein zweiter gleicher Plan-Eintrag desselben Tages fälschlich miterwischt werden.

§2 — status: planned | consumed | expired (rein abgeleitet)

Jeder Plan-Eintrag (in GET /api/plan/{start} und im planned-Block von /api/day, §3) trägt ein abgeleitetes status-Feld:

status Bedingung
consumed consumed = 1 (gegessen gewinnt immer, nie expired)
expired consumed = 0 und date < heute (vergangen + nie gegessen)
planned sonst (heute oder Zukunft, ungegessen)

expired ist KEINE DB-Spalte — bewusst rein abgeleitet aus (consumed, date, heute): gespeichert bräuchte es einen täglichen Cron zum Umklappen und würde vom echten Datum driften. Abgeleitet ist es immer korrekt. Der Client blendet expired im Standard-Flow aus (kein ewiger Vorwurf für übersprungene Tage), kann es aber in einer Historie zeigen.

GET /api/plan/{start} nimmt optional ?today=YYYY-MM-DD (pinnt den status-Stichtag für Tests/Determinismus; Prod lässt weg → Berlin-Tag). Plan-Einträge tragen jetzt auch ihr date mit.

§3 — planned in /api/day (ein Call fürs Heute-Scharnier)

GET /api/day/{user_id}/{date} liefert zusätzlich planned: [...] — die an diesem Tag noch ungegessenen Plan-Einträge in PlanEntry-Form (id, date, meal, dish_id/ food_id, name, image, kcal, protein_g/carbs_g/fat_g, servings, consumed, shared, status, source). Der Heute-Scharnier braucht damit keinen zweiten /api/plan-Call und keine Montags-Wochenmathe. meal_plan ist nicht user-gebunden (familienweiter Plan) → der planned-Block ist für beide Profile identisch. Optionales ?today= pinnt auch hier den status-Stichtag.

§4 — Idempotenz C1/C2 (ADR-42, bestätigt live)

Budget bleibt strikt Ist-Stand (Synthese): KEIN planned_kcal/Zukunftsbudget — das planned ist reine Anzeige, es geht erst beim Essen ins Budget.

§5 — Mehrtages-Archiv: GET /api/diary/range/{user_id} (W4 Teil 2)

Fürs Tagebuch-Archiv (Wochen-Rückblick + Trends) — ein Call statt N×/api/day:

GET /api/diary/range/{user_id}?days=30

{ "user_id": 1, "days": 30, "from": "...", "to": "...", "entries": [
  { "date", "kcal_eaten", "kcal_budget", "kcal_activity",
    "protein_g", "carbs_g", "fat_g", "entry_count" }, … ] }

§6 — Pro-Nutzer-Pläne + planned_kcal (W4 #68, Gerätetest-Funde)

Problem (B1): meal_plan war familienweit/ownerlos → „nur für mich" geplant tauchte nach Profilwechsel auch beim Partner auf.

Besitzer-Modell: meal_plan hat jetzt user_id (Besitzer = wer plant). Scope bewusst 1 Besitzer + shared-Flag (kein Mehr-Empfänger-Modell — reicht für 2 Profile; shared=1 „Gemeinsam" gehört beiden): - POST /api/plan nimmt user_id (jetzt Pflichtfeld). Unbekannter User → 404. - Sichtbarkeit: GET /api/plan/{start}?user=N und das planned[] in GET /api/day/{N}/{date} zeigen nur Pläne mit user_id = N ODER shared = 1. GET /api/plan ohne user (=0) bleibt Familien-/Admin-Sicht (alles) — rückwärtskompatibel. Plan-Einträge tragen jetzt ihren Besitzer als user_id mit. - Auto-Match (§1) ist zusätzlich auf user_id gescoped: kocht jemand ein Gericht über den allgemeinen Kochmodus, wird nur ein eigener (nicht-shared) Plan abgehakt — fremde private Pläne bleiben unberührt. - Buchen/Wir-Modus (plan/eat) unverändert: shared=1 bucht weiter in beide Tagebücher (ADR-28). - Migration ownerloser Bestands-Pläne → Primär-Nutzer (MIN(users.id)), shared unangetastet. Begründung: ein shared-Flip hätte die Eat-Semantik der Doppelbuchung gekippt; das Primär-Konto ist der natürliche Default; Partner- Sichtbarkeit ist per „Gemeinsam" neu planen heilbar. (Prod: 12 Pläne, 0 ownerlos.)

planned_kcal (Vorschau-Bogen im Tagesring): GET /api/day/{uid}/{date} liefert zusätzlich planned_kcal + planned_protein_g/planned_carbs_g/planned_fat_g = Summen über die (user-gefilterten) ungegessenen Plan-Einträge des Tages. Reine Anzeige — zählt NICHT als gegessen (geht erst beim Essen via plan/eat ins Budget); strikt Ist-Stand-Budget bleibt unberührt.

§7 — Finish-Welle (W4 #70/#71, Gerätetest)

§7.1 Gericht-Wochen-Status: GET /api/dishes/{id}/week-state?user=N&today=ISO{eaten: {date, shared}|null, planned: {date, shared, plan_id, meal}|null} für die Kalenderwoche von today (Montag–Sonntag), user-gefiltert (eigene + shared=1). Beide Felder werden geliefert, der Client entscheidet die Anzeige (eaten → „hast du schon gegessen" + Tagebuch; planned → „geplant für …" + Umplanen/Kochen). eaten = jüngster Tagebuch-Eintrag dieses Users mit dem Gericht in der Woche (egal ob geplant); shared = kam aus einem Wir-Modus-Plan (diary.plan_id → meal_plan.shared). HF kommt pro Woche nur 1× vor → höchstens je eins. Unbekanntes Gericht → 404.

§7.2 HF-1×-Check auf die Woche gescoped: POST /api/plan (und das Umplanen PATCH /api/plan/{id} {date}) blockt ein HF-Gericht nur noch, wenn dasselbe HF-Gericht in derselben Kalenderwoche schon liegt (vorher global über alle Wochen). HF-Gericht ist damit pro Woche max 1×, in anderen Wochen wieder planbar. PATCH schließt den bewegten Eintrag selbst aus (id != plan_id).

§7.3 Quellen-Pillen in /api/diary/range: je Tag zusätzlich activity_sources: [{source, kcal}] (verdiente kcal je Aktivitäts-Quelle eGYM/Garmin/ manuell, nach source sortiert) und had_hellofresh: bool (an dem Tag ein HF-Gericht gegessen → HF-Logo-Pille ohne kcal). Ersetzt das verwirrende nackte „+783" durch benannte Pillen. (Per-Quelle gerundet; Summe kann ±1 vom gerundeten kcal_activity abweichen — reine Anzeige.)

§7.4 shared am Tagebuch-Eintrag (#71): GET /api/day/{uid}/{date} entries[] tragen je Eintrag shared: bool — stammt der Eintrag aus einem Wir-Modus-/gemeinsamen Plan (in beide Profile gebucht)? Hergeleitet aus diary.plan_id → meal_plan.shared; ohne Plan-Herkunft (Einzel-Log) = false. Damit hat der Client shared an allen drei Stellen: geplant-Kachel (PlanEntry.shared, §3), Gericht-Hinweis (week-state.shared, §7.1), Tagebuch-Eintrag (entries[].shared).