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:
- Gesucht wird der älteste
meal_plan-Eintrag mitdish_id = {id},date = (p.date ?? heute),consumed = 0,shared = 0(LIMIT 1). - Treffer →
meal_plan.consumed = 1unddiary.plan_id = <plan_id>. Die Verknüpfung schließt den ADR-23-Kreislauf gratis: das Löschen/Zurücknehmen des Tagebuch-Eintrags setzt den Plan überplan_idwieder auf offen. - Antwort trägt dann zusätzlich
"matched_plan_id": <id>(fehlt, wenn kein Match).
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)
- C1
POST /api/plan/{id}/eat:consumed-Guard (schon gegessen → nichts schreiben, bestehendeexisting_entry_idszurück) + idem_keyplan:{plan_id}:{uid}. - C2
POST /api/dishes/{id}/log: optionaleridem_key(z. B.cook:{dish}:{user}: {date}); der Vorrats-Abzug feuert nurif not res.idempotent→ kein Doppel-Abzug. Auto-Match (§1) ist an dieselbe Bedingung gekoppelt.
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" }, … ] }
- Absteigend nach Datum, Fenster =
[heute − days + 1 … heute].?today=(optional) pinnt das Range-Ende (Determinismus).dayswird auf [1, 366] geklemmt; Default 30. Unbekannter User → 404. - Geliefert werden nur Signal-Tage: Tage mit ≥1 Tagebuch-Eintrag ODER ≥1 Aktivität. Der Client muss leere Tage nicht ausfiltern; für Trend-Achsen ergeben sich Lücken aus den fehlenden Daten.
entry_countzählt nur Tagebuch-Einträge → ein reiner Aktivitäts-Tag hatentry_count=0, erscheint aber (kcal_activity>0, Budget gehoben). Wer leere Tage ausblenden will, filtert aufentry_countund beachtet, dass Aktivitäts-Tage so rausfallen — sonst zusätzlichkcal_activityprüfen.- Anti-Drift: Budget/Makros sind identisch zu
day_summarygerechnet (kcal_budget = base + Tages-Aktivität, gleiche Rundung) — per zwei gruppierten Queries (diary GROUP BY date, activities GROUP BY date) + einem User-Lookup, nicht N Einzel-day_summary-Aufrufe. Der Client zeigt nur, der Server rechnet.
§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).