Energie — Vertrag (Budget v2 · Erhaltungsmessung · Energiekurve · Trainingsplan · Vorlagen · Woche)
StratoClaude → MacClaude, 28.08.2026 (Energie-Epic SILV-415, Backend-Subs SILV-416–421; Coach-Spec 28.08. =
app/ios-native/reimplementierung/energiekurve/ spec-coach-2026-08-28.md, Syntheseapp/ios-native/syntheses/energie.md). Antwort auf die Bus-Bestellung; byte-genau gegen §5 der Synthese, Abweichungen markiert. Der Server erzeugt die Bedeutung, der Client zeichnet nur (Anti-Drift). Code:app/backend/energy.py(Rechenkern),app/backend/main.py(Endpoints, Budget-Kern, Hooks),app/backend/intervals.py(Pull),db.py(Migration). Contract-Tests:app/tests/contract/test_budget.py,test_energy_maintenance.py,test_energy_curve.py,test_plan_sessions.py,test_template_meals.py,test_energy_week.py. Ersetzt die Formel inbudget-vertrag.md(Weg B, SILV-336 — dort nur noch Verweis).
Alle neuen Felder additiv; Legacy-Felder bleiben lesbar (Mapping unten). Zeiten
sind lokal Europe/Berlin zum jeweiligen Tag (HH:MM); ISO-Samples mit Offset werden
serverseitig nach Berlin umgerechnet (Spec §11: Zone des Tages, nicht des Geräts).
1. Budget v2 (SILV-416) — ersetzt Weg B
basis = erhaltung_gemessen − activity_mean(56 d) # Quelle: override > measured > fallback
defizit = rate_kg_per_week × 7700 / 7 # = rate × 1100
budget = max(basis − defizit + activity_kcal, boden) # gesund gekappt (capped=true)
boden = boden_pct × (basis + activity_kcal) # boden_pct Default 0,6
- Kein
training_bonus_pct, keinetraining_threshold_kcalmehr wirksam (DB-Spalten bleiben, fallen aus GET/PUT; unbekannte Felder im PUT werden ignoriert). activity_kcal(Abweichung 1, Bus 28.08.) = gemessener Anteil (window-claimer,garmin > watch > iphone > other, nie summiert) +expected_kcalgeplanter Einheiten, die als getan gelten:confirmedimmer;plannedabslot_end(heute) bzw. ganztägig (Vergangenheit =assumed_done, Zukunft = Planwert für die Wochenansicht);matchednie (die Messung trägt),skippednie (Budget fällt umexpected_kcal). Heute vorslot_end: nur Projektion in der Kurve, nicht im Budget (Spec §4.2.2).- eGYM-Schätzung ohne Messung im Fenster zählt 50 % (Budget + Kurve). Die Zeile in
activities[]trägt dafürkcal_measured(bool) undkcal_counted(Abweichung 2):kcalbleibt der Display-Wert (z. B. 320),kcal_counted= was ins Budget geht (160). Invariante: Σkcal_counted(Tag) == gemessener Anteil vonactivity_kcal. - Basis-Override heißt
erhaltung_basis_override_kcal(Basis, nie Erhaltung). Der alteerhaltung_override_kcal(Weg B) wird nie zur Basis zurückgerechnet (Spec §1.2) und läuft mit v2 aus — Dennis' 2.530 gilt nicht mehr, die Basis kommt aus der Messung (erhaltung_basis_source=measured), bis ein Basis-Override gesetzt wird. - Settings-Versionen tragen
formula_version(1 = Weg B, 2 = v2). Eine v1-Version liefert ihrenboden_pctnicht (er war auf die Erhaltung bezogen) → v2-Default 0,6.
DTO (in day_summary = /api/day, /api/bootstrap, alle Diary-/Aktivitäts-Mutationsantworten; /api/diary/range je Eintrag; Snapshot day_budget)
| Feld | Typ | Bedeutung |
|---|---|---|
erhaltung_basis_kcal |
int | Basis des Tages (Nicht-Trainings-Erhaltung) |
erhaltung_basis_source |
measured|fallback|override |
Herkunft der Basis |
activity_mean_kcal |
int|null | Ø activity_kcal im 56-d-Fenster (auch ohne Erhaltungs-Messung definiert) |
budget_kcal |
int | basis − defizit + activity (gekappt) — die Zahl fürs Ring-Ziel |
defizit_kcal |
int | rate × 1100 |
boden_kcal |
int | v2-Boden = boden_pct × (basis + activity) |
boden_pct |
float | wirksamer Boden-Anteil |
activity_kcal |
int | s. o. (gemessen + assumed/confirmed Plan) |
capped / cap_hint |
bool / string|null | Gesund-Kappung |
formula_version |
int | 2 (live + neue Freezes); 1 = Weg-B-Snapshot (Felder abgeleitet) |
recomputed_at |
ISO|null | Spec §4.4: Tag nach dem Freeze durch Nachsync neu verrechnet |
zielrichtung, deadband_kcal |
unverändert (SILV-407) | |
erhaltung_kcal, erhaltung_source, erhaltung_calc_kcal |
Legacy, s. u. |
Legacy-Mapping (volle Ring-Runde bleibt lesbar): erhaltung_kcal = basis + activity_kcal
· ziel_kcal = budget − activity_kcal · ziel_eff_kcal = budget_kcal · training_bonus_kcal = 0
· kcal_budget = budget_kcal · kcal_base = erhaltung_kcal · kcal_left = budget − eaten ·
erhaltung_calc_kcal = erhaltung_gemessen (Hinweis, auch bei Override).
v1-Snapshots (formula_version=1, vor 28.08. eingefroren): erhaltung_basis_kcal :=
erhaltung (Weg-B-Erhaltung), budget_kcal := ziel_eff, defizit_kcal := rate × 1100,
activity_mean_kcal = null — kein Zurückrechnen (Dennis 21.06.).
Settings (GET/PUT /api/budget/settings, versioniert valid_from = heute)
PUT Body: user_id, rate_kg_per_week (−2…2), erhaltung_basis_override_kcal
(null | 800…6000), boden_pct (0,3…0,9; Default 0,6), deadband_kcal (0…1000),
protein_g_per_kg (0,8…3,0; Default 1,6). 422 außerhalb der Bereiche.
GET ?user_id&date= liefert die effektiven Werte + valid_from, formula_version und die
Hinweise erhaltung_calc_kcal (gemessene Erhaltung), erhaltung_basis_calc_kcal,
activity_mean_kcal, erhaltung_se_kcal, maintenance_quality — die
Client-Live-Vorschau MUSS mit genau diesen Zahlen die Formel oben rechnen.
Freeze (03:45) + Nachsync (Spec §4.4, AC 6)
Der Freeze friert erhaltung_basis, activity_mean, erhaltung_basis_source, defizit,
boden_pct (+ formula_version=2) ein. Nur activity_kcal darf danach nachziehen:
bei jedem Reconcile (Health-Sync/Garmin/eGYM), manueller Aktivität oder Plan-Status-
Änderung für einen eingefrorenen Tag werden activity_kcal, budget, boden,
ziel(_eff), erhaltung, capped neu verrechnet und recomputed_at gesetzt
(unveränderte Aktivität → kein Schreiben). v1-Tage (Weg B, formula_version 1) werden
seit SILV-426 NUR in activity_kcal + v1-Trainingsbonus/ziel_eff nachgezogen (v1-Formel:
bonus = pct × activity, wenn activity > threshold) — nie mit v2-Mathe; Erhaltung/Ziel/Boden
bleiben eingefroren. Grund: ein nach dem Freeze korrigierter Tag (Dedup-Reparatur, später
Uhr-Sync) bliebe sonst für immer auf dem falschen activity_kcal. SSE day_changed
feuert über den bestehenden Chokepoint (_la_budget_sync).
2. Erhaltungsmessung (SILV-417) — GET /api/energy/maintenance?user_id&days=56[&today=]
{ erhaltung_gemessen, erhaltung_basis, activity_mean, intake_mean,
slope_kg_per_week, slope_se, erhaltung_se, n_weight_points, n_logged_days,
window_from, window_to, quality: "ok"|"short"|"gap",
suspect_incomplete_days: [YYYY-MM-DD…], phantom_days: [YYYY-MM-DD…],
days, ref_date, reason? }
- Fenster fest
days(Default 56, min 28, max 120), verankert am letzten geloggten Tag ≤ref_date(Log-Tag = Tag mit Σ kcal > 0). Lücken ≠ Nulltage. intake_meanüber die Log-Tage;activity_meanüber alle Kalendertage des Fensters (Tage ohne Aktivität = 0; der Gewichtstrend integriert ebenfalls alle Tage). Nur gemessene bzw. gematchte Aktivität (activities,kcal_counted) — nieassumed_done-Planwerte geplanter, nicht synchronisierter Einheiten (SILV-428, Coach 28.08.: sonst driftet die Basis, sobald Einheiten geplant, aber nicht synchronisiert sind). Das Budget des Tages zähltassumed_doneweiterhin (§1); die Erhaltungsmessung nicht.- Der Referenztag heißt im Query
today=(nichtref_date);ref_dateist nur der Antwort-Schlüssel. - Gewicht: OLS über die Tagesreihe (ein Punkt/Tag: fithub bevorzugt, sonst Median),
slope_se= Standardfehler der Steigung (kg/Woche),erhaltung_se = slope_se × 1100. Phantomfilter auf den Roh-Punkten: |Δ| > 3 kg gegen den Median der übrigen Punkte im ±3-Tage-Fenster (≥ 2 Nachbarn) → ignoriert, inphantom_daysausgewiesen. quality:shortbei < 42 Log-Tagen (auch < 14 Log-Tage oder < 3 Gewichtspunkte → Zahlennull+reason),gapbei ≥ 3 zusammenhängenden Tagen ohne Eintrag zwischen erstem und letztem Log-Tag des Fensters (Abweichung: eine führende Leerstrecke = „noch nicht so lange getrackt" ist kein Loch — das bildetshortab), sonstok.suspect_incomplete_days: ≤ 2 Einträge und < 1.200 kcal — zählen, aber markiert./api/activity/tdee?user_id[&weeks=8]bleibt als Alias (Client-Übergang): liefert dasselbe Objekt plus Legacy-Schlüsseltdee(=erhaltung_gemessen),avg_intake,weight_trend_kg_per_week,logged_days,weight_points,span_days,weeks.- Der Freeze rechnet mit dem Fenster, das am Zieltag galt (
ref_date= Zieltag).
3. Energiekurve (SILV-418) — GET /api/energy/curve?user_id&date[&today=&now=HH:MM]
{ date, now: "HH:MM"|null, bmr_kcal, resting_source: "basis", # seit SILV-428 immer "basis"
points: [{t:"HH:MM", bilanz:int, state: measured|modeled|projected}], # 00:00 … 23:55 + "24:00"
segments: [{start, end, kcal, kind: resting|activity|neat,
state: modeled|measured|discarded,
source: formula|garmin|apple_health|iphone|egym|manual|plan,
planned_session_id?, activity_id?}],
intake: [{t, kcal, state: actual|planned, entry_id?, planned_meal_id?, label?}],
reference: { floor_kcal: -400, target_end_kcal: -defizit },
kpi: { low_kcal, low_at:"HH:MM", hours_below_400, end_kcal, end_delta_kcal },
budget_kcal, activity_kcal, recomputed_at: ISO|null }
- Rechnung ab 0:00 (Anzeige darf beim Aufwachen beginnen), 1-min-Raster, Ausgabe alle
5 min + Endpunkt
"24:00"(= 23:59:59).bilanz(t) = Σ Aufnahme(τ ≤ t) − Verbrauch(0:00…t)— der Sprung zählt am Zeitstempel (die Spec-Tabellen §9 notieren 13/16/19 Uhr vor dem Sprung; Kennzahlen identisch, §9.1 auf ±5 kcal reproduziert: Tiefpunkt −1.509 um 10:49, 10,8 h, Ende −216). Für §9.3 liegt das Minimum rechnerisch vor dem Frühstück (07:29 → −629; 12:29 → −599 ist der zweite Tiefpunkt) und es sind 5,1 h < −400 — Folge der gelockten 0:00-Regel, die Spec-Tabelle zählte die Nacht nicht. now:HH:MMfür heute (Pin nur für Tests),nullfür Vergangenheit (alles links, keine Projektion),"00:00"für Zukunft (alles Projektion) — Abweichung 7.- Ruhe (SILV-428, Coach-Antwort 28.08. §1.4 — löst Abweichung 5 „Samples zuerst" ab):
immer
Basis/1440je Minute, konstant, auch nachts;bmr_kcal= Basis,resting_source="basis", genau EINresting-Segment00:00–24:00(modeled/formula). Garmin- „Ruheenergie" in Health ist exakt die Formel5 − 6,12·Alter + 7,63·Größe + 12,2·Gewicht(27.08.: 2.015,7), Apples Wert ist Apples Formel — beides keine Messung; wo das iPhone gewann, kam die niedrigere Formel durch (1.941) und Kurve (−355) und Budget (−489) widersprachen sich am 27.08. um 134 kcal. Per Konstruktion gilt jetzt: Endstand Kurve = Aufnahme − (Basis + Aktivität),end_delta_kcal = Aufnahme − Budget(Budget getroffen ⇔ Kurve auf Ziel). Ruhe-Samples werden weiter gespeichert (health_resting_energy_sample), aber von Kurve und Verbrauch nicht gelesen (höchstens später als Debug-Overlay). Die Spec- Fixtures §9.1/§9.3 sind mit Basis statt 84 kcal/h nachgerechnet: §9.1 Tiefpunkt −1.592 um 10:49, 13,9 h, Ende −400 = 2.950 − (2.200 + 1.150); §9.3 Ende −220 = −Defizit,end_delta0. verbrauch_kcal(GET /api/daysummary,/api/diary/range, Bootstrap) = dieselbe Zahl: Basis + Aktivität (SILV-428; löst SILV-405 „nur mit Ruhe-Samples, sonst null" ab — nie mehrnull).- v1-Tage (SILV-441, Coach-Befund 29.08. §1.1): Tage vor
valid_fromder v2-Settings liegen alsformula_version 1im Snapshot; ihre „Basis" (erhaltung_basis_kcal, abgeleitet = eingefrorene Erhaltung, z. B. 2.530) ist die alte Erhaltung INKLUSIVE Aktivität (Weg B) — als Ruhe ~455 kcal/Tag zu viel. Das Budget/der Snapshot bleibt unangetastet (§1, keine Rückrechnung). Die Kurve (Analysewerkzeug) nimmt für v1-Tage die aktuelle v2-Basis (Override > gemessene Basis zum Stichtagtoday> Fallback; ein v1-Override zählt nie) —bmr_kcal= diese Basis,resting_source = "basis_v2_rueckwirkend". Folge 27.08.: Ende −254 statt −709. Für v2-Tage unverändert"basis".verbrauch_kcal/end_deltav1-Tage: Referenz bleibt −Defizit des Snapshots. kpi.hours_below_400 = nullfür vergangene Tage ohne einen einzigen Tagebuch-Eintrag (SILV-441; dieselbe Regel wieenergy_weekseit SILV-428 — „18 h leerer Tag" ist keine Aussage). Heute (laufend) und Zukunft: Zahl.- Aktivität: benannte Fenster (Garmin-Lauf, HK-Workout, eGYM-Session) als
measuredmitkcal_counted(eGYM ×0,5),activity_id; NEAT = nicht beanspruchte gemessene Aktiv-Energie alskind=neat,measured(Projektion 0, Spec §4.2.6); manuelle Aktivität ohne Fenster endet beicreated_at(gleicher Tag), sonst 12:00, Dauer =minutesoder 60. Geplante Einheiten:matched→ nichts (Messung trägt);skipped→discarded(zählt 0, bleibt sichtbar); sonstmodeled/source=planmitexpected_kcalim Slot (assumed_doneabslot_end, davor nur rechts von „jetzt"). - Aufnahme: Diary-Einträge (
time) alsactual+entry_id. Projektion (nur heute/ Zukunft,t ≥ now): Vorlagen-Mahlzeiten (status=planned) mit ihrer Zeit und Gericht-/Artikel-Plan-Einträge (kind=dish,consumed=0, eigene oder shared) mit Slot-Default 07:30 / 12:30 / 16:00 / 19:00 (fruehstueck/mittag/snack/abend), 1 Portion (Abweichung 6) — beidesstate=planned+planned_meal_id(=meal_plan.id) +label. Vergangene, nicht ersetzte Plan-Mahlzeiten fallen aus der Kurve (bleibenplanned). - Punkt-Zustand:
t > now→projected; sonstmodeled, wenn ein modelliertes Aktivitäts-Segment die Minute deckt (gestrichelt zeichnen), sonstmeasured. kpiaus der 1-min-Reihe;end_delta_kcal = end − (−defizit). Historische Tage: gleicher Endpoint (wird stets aus den Samples gerechnet, nichts persistiert);recomputed_atspiegelt den Budget-Snapshot (§1).
4. Trainingsplan (SILV-419) — Intervals.icu read-only
- Pull: Cron 06:35 (
POST /api/intervals/sync, intern) +POST /api/sync/trigger? source=intervals(App-Start, debounced wie garmin/egym). Fenster heute−3 … heute+14,GET /athlete/{id}/events?category=WORKOUT, Basic-AuthAPI_KEY:<Key>. Key liegt gitignored inapp/data/intervals/<slug>/api_key(+ optionalathlete_id, Default 0); ohne Verzeichnis läuft der Worker leer. Vom Plan entfernte Events werden gelöscht, solange die Session nochplannedist; Silverscale schreibt nie zurück (Lock 28.08.). - Tabelle
planned_session;GET /api/plan/sessions?user_id[&from&to&today&now](Default heute … +14) →{ user_id, from, to, sessions: [ … ] }mit{ id, external_id, source: intervals|manual, date, slot_start, slot_end, type: easy|long|interval|threshold|strength|other, type_manual, name, description, planned_duration_min, expected_kcal, kcal_per_min, kcal_per_min_learned, template_id, status: planned|assumed_done|confirmed|matched|skipped, matched_activity_id, icu_training_load }.assumed_doneist abgeleitet (kein Cron):planned+slot_enderreicht (heute) bzw. Datum vergangen. - Typ-Mapping (Spec §5.1) aus dem Namen: „long" → long · „ I "/„Intervall"/„VO2" →
interval · „ T "/„Schwelle"/„Tempo" → threshold · „eGYM"/„Kraft" → strength · sonst easy.
Slot:
start_date_local+moving_time; fehltmoving_time(eGYM-Events), giltend_date_local, sonst 60 min.PATCH /api/plan/sessions/{id} {type|name|slot_start| slot_end}— ein manueller Typ (type_manual=1) überlebt den Pull. - expected_kcal = Dauer × kcal/min(Typ); Startwerte easy 14,0 · long 13,5 · threshold
15,0 · interval 15,5 · strength 5,0 (other 10,0); gelernt = Mittel (kcal ÷ Dauer der
Messung) der letzten 5
matchedEinheiten des Typs →kcal_per_min_learned=true, wird bei jedem Match auf alle offenen Sessions des Typs nachgezogen (AC 7). - Zuordnung (bei jedem Reconcile/Sync-Sweep): Aktivität mit Fenster, Start in
slot_start − 3 h … slot_end + 3 h, Typ kompatibel (Lauf ↔ easy/long/interval/threshold; eGYM/Kraft ↔ strength; other ↔ alles), nächster Start gewinnt →matched+matched_activity_id(=activities.id). Ohne Treffer undslot_end + 3 hüberschritten →skipped(skipped_by=auto); kommt die Aktivität später (Garmin-Latenz), holt der nächste Sync sie zurück aufmatched(Abweichung: kein endgültiger Ausfall durch Latenz). Manuell:POST …/{id}/status {status: confirmed|skipped|planned},POST …/{id}/match {activity_id|null}(null = lösen → planned). - Manuelle Einheit (additiv, vor dem Key nützlich):
POST /api/plan/sessions {user_id, date, slot_start, slot_end?|planned_duration_min?, type, name?};DELETE …/{id}. - Budget-Folge:
skippedsenktactivity_kcal/Budget umexpected_kcal(auch für eingefrorene Tage →recomputed_at),confirmedhebt sofort; SSEday_changed+plan_changed.
4a. Einheiten bearbeiten, sync-geschützt (SILV-449, Coach-Spec §6.5/N9 — Lock Dennis 04.09.: lokal überschreiben, Intervals unangetastet)
- DTO additiv:
user_edited: bool,skipped_by: auto|user|null(auch imsession-Block von/api/plan/week, dort zusätzlichsource). PATCH /api/plan/sessions/{id} {type|name|slot_start|slot_end|date|planned_duration_min}:date(YYYY-MM-DD) verschiebt den Tag;planned_duration_minsetzt die Dauer (slot_endfolgt),slot_endsetzt umgekehrt die Dauer;expected_kcalfolgt der Dauer (außer matched/skipped). Jede Nutzer-Änderung (PATCH,POST …/status) setztuser_edited=1. Wirkung wie ein Sync: Budget/Kurve für alten + neuen Tag, Vorlagen-Regel §5d (reine Vorlagen-Tage regenerieren, sonstsession_changed_at; Coach-Zeilen bleiben), SSEday_changed+plan_changedje Tag.- Pull-Schutz:
user_edited=1→ der Intervals-Pull überschreibt die Einheit nicht und löscht sie nicht, wenn Intervals sie nicht mehr liefert (nurraw_json/icu_training_loadlaufen mit); Match/Auto-Skip laufen weiter. Ingest zählt sie alsprotected. POST /api/plan/sessions/{id}/reset→{ok, session}= „auf Intervals zurücksetzen": Werte neu ausraw_json,user_edited=0,type_manual=0, eine manuelle Absage (skipped_by=user) wird zurückgenommen, ein Match bleibt.source=manual→ 400, ohne Rohdatensatz → 400.- Geister-Einheit im Tagebuch (D4):
GET /api/dayliefert die Einheiten des Tages imactivities[]-Block — Server-Wahrheit, kein Client-Merge. Nicht gematcht → Zeile{id: -session_id, source: "planned_session", session_id, status: planned|confirmed|skipped, skipped_by, user_edited, name, emoji, type, slot_start, slot_end, minutes, expected_kcal, kcal: 0, kcal_measured: false, kcal_counted, date, user_id, ext_ref, created_at};kcal_counted= Anteil, der JETZT im Budget zählt (confirmed immer; planned abslot_endbzw. Vergangenheit; skipped 0) — dieselbe Regel wieactivity_kcal, keine Doppelzählung. Gematcht → keine Geist-Zeile; die Ist-Aktivität trägt additivsession_id+status: "matched". - Sync-Ergebnisliste (N10):
POST /api/sync/trigger?source=intervals&wait=1blockiert bis zum Sync-Ende (Deadline 20 s →reason: "timeout",result: null, Sync läuft weiter), Debounce 30 s statt 5 min;result = {lines: [String], new_events, updated, removed, matched, skipped_protected, moved_rows, wellness_pulled, at},linesserver-formuliert („2 neue Einheiten", „1 Einheit gematcht (Long Run 29.08.)", „1 geschützte Einheit übersprungen", „3 Vorlagen-Zeilen verschoben"), leer →["Nichts Neues"]. Beidebounced/running(202) trägtresultdas letzte Ergebnis.wellness_pulled= der Sync hat Intervals/wellness(7 Tage: HRV, Schlaf, Ruhepuls, Gewicht) mitgezogen (SILV-450). Ohnewaitunverändert. Testmode:POST /api/test/intervals-eventsliefert dasselberesult.
5. Vorlagen-Mahlzeiten im Wochenplan (SILV-420)
- EIN
meal_plan-Eintrag, additiv:kind: dish|template_meal(Gerichte/Artikel =dish),label,time(HH:MM),planned_kcal/protein_g/carbs_g/fat_g,template_id(long|interval|threshold|easy|strength|rest),planned_session_id,status: planned|replaced|skipped,replaced_by_entry_id. Küche bleibt Owner der Plan-Mechanik. - PlanEntry-DTO (in
GET /api/plan/{monday}days[].entries[]undGET /api/dayplanned[]): jeder Eintrag trägt jetztkind; fürtemplate_meal:source: "template",name=label,label,time,template_id,planned_session_id,replaced_by_entry_id,status(replaced/skippedexplizit, sonstplanned/expired),kcal/protein_g/carbs_g/fat_g= Planwerte,dish_id/food_id/image = null,servings 1./api/day.planned[]+planned_kcalenthalten nur noch offene (planned) Vorlagen. - Erzeugen:
POST /api/plan/template-meals/generate {user_id, date, template_id?, planned_session_id?}— ohnetemplate_idaus der Einheit des Tages (long > interval > threshold > …), sonstrest. Layout nach Tageszeit (SILV-428, Coach-Antwort 28.08. §2.2): - long vor 11:00: Frühstück Slot−90 (12 %), Gels (s. Gel-Regel; je 3,5 %), Shake Slot-Ende+10 (22 %), Mittag 13:00 (25 %), Snack 16:00 (7 %), Abend 19:00 (27 %).
- Gel-Regel (SILV-440, Coach 29.08. §1.2): Gel 1 bei Slot-Start + 45 min (≈ km 8); Gel 2 nur bei Dauer > 90 min bei Slot-Start + 80 min; Dauer < 75 min → kein Gel. Weggefallene Gel-Anteile gehen ins Frühstück (Summe bleibt 100 %). Beispiel 93 min ab 08:30 → 09:15 + 09:50 (vorher 10:15 = nach Slot-Ende 10:03); 80 min → nur 09:15.
- Snack-Regel (SILV-440, Coach 29.08. §2.2): endet der Slot nach 15:00, entfällt der 16:00-Snack, sein Anteil geht ins Abendessen (sonst stehen Erholungsmahlzeit Slot- Ende+15 und Snack 16:00 übereinander); die Erholungsmahlzeit bleibt bei Slot-Ende+10/+15. Beispiel Easy 13:30–15:30 (Mittags-Layout): Mittagessen mit Shake 15:45, kein Snack, Abendessen 36 %.
- long ab 11:00: Frühstück fest 07:30 (12 %), Snack 30 g KH Slot−90 (5 %), Gels wie gehabt, Mittagessen mit Shake Slot-Ende+10 (42 % — der Shake-Anteil wird Mittag), Snack 16:00 (7 %), Abend 19:00 (27 %).
- easy/threshold/interval/strength vor 11:00 (Vormittags-Layout): Frühstück leicht Slot−75 (14 %), Shake ≥ 30 g Protein Slot-Ende+15 (18 %), Mittag 13:00 (28 %), Snack 16:00 (8 %), Abend 19:00 (28 %), Casein Schlafen−30 = 22:30 (4 %).
- easy/threshold/interval/strength 11:00–16:00 (Mittags-Layout): Frühstück 07:30 (20 %),
Snack 30 g KH Slot−90 (6 %), Mittagessen mit Shake ≥ 40 g Protein Slot-Ende+15 (32 %,
protein_g≥ 40 hart), Snack 16:00 (8 %), Abend 19:00 (28 %), Casein 22:30 (6 %). (Die Antwort nennt 11:00–14:00; 14:00–16:00 läuft ebenfalls über dieses Layout, damit keine Lücke zum Abend-Layout entsteht — Annahme StratoClaude.) - ab 16:00: Abend-Layout der Spec (Hauptmahlzeit Slot−2,5 h, Slot−45-KH-Punkt bei interval/threshold, Shake Slot-Ende+15, Abendessen leicht, Casein 22:30) — unverändert.
- Vortag 19:00 KH-betont: long +300, threshold +200 (SILV-428); idempotent (kein
zweiter Aufschlag), Eintrag wird angelegt, wenn der Vortag keine Vorlage hat.
Der Intervals-Pull erzeugt sie automatisch für Tage mit
Einheit (ab heute) — nach der Sync-Regel §5d (SILV-438); Ruhetage auf Anfrage.
Idempotent: noch
plannedVorlagen des Tages werden ersetzt,replaced/skippedbleiben. Einheit des Tages wird immer verknüpft (SILV-440): auch bei explizitemtemplate_idliefern Slot-Zeiten undplanned_session_iddie Einheit des Tages (long > interval > threshold > …, nicht skipped) — alle Vorlagen-Zeilen und die Vortags-19:00-Zeile tragenplanned_session_id ≠ null, sobald der Tag eine Einheit hat. Anteile ×budget_kcaldes Tages (immer inklusiveexpected_kcalder Einheit, solange sieplanned/confirmedist — unabhängig von der Uhrzeit, SILV-439 Coach-Defect D1: die Vorlage plant den ganzen Tag;matched→ Messung,skipped→ 0; die „zählt ab Slot-Ende"-Regel §1/§4 gilt nur für das Live-Tagesbudget in/api/day), Zeiten relativ zum Slot (Spec §5.2), Proteinverteilung: Casein 35 g, Rest gleichmäßig auf die Protein-Portionen, ≥ 30 g je Portion; KH/Fett Planwerte (Heuristik 25 % Fett bei Mahlzeiten). Antwort{ ids, eve_before_id, budget_kcal, protein_goal_g, template_id, planned_session_id }. - Ersetzen (Spec §2.2, AC 5):
POST /api/diaryersetzt die nächsteplannedVorlagen-Mahlzeit desselben Tages ±90 min (Antwort additivreplaced_plan_id);DELETE /api/diary/{id}macht sie wiederplanned. Manuell:PATCH /api/plan/{id} {replaced_by_entry_id: int|null}(null = lösen),{status: planned|skipped},{time}.POST /api/plan/{id}/eatauf eine Vorlage legt den Diary-Eintrag mit den Planwerten an (Zeit/Label/Makros) und ersetzt genau diese Vorlage (idempotent).
SILV-451 (N13, P3, N14 — 05.09.2026):
- Produkt-Zeilen (N13): trägt ein Artikel foods.plan_role = "gel" (PATCH /api/foods/{id}
{plan_role: "gel"}, genau ein Artikel je Rolle, "" löscht), bucht die long-Vorlage ihre
Gel-Zeilen mit den Etikettwerten je Portion (portion_g, sonst 100 g) und food_id statt des
3,5-%-Anteils (NRGY Gel 45: 180 kcal / 45 g KH statt 111 / 27,8). Preview + PlanEntry tragen
food_id auf der Vorlagen-Zeile. Prod: Artikel 1350 „Nrgy unit gel" trägt die Rolle.
- Vortags-Zeile (Defect P3): der KH-Bonus (long +300 / threshold +200) hängt sich an die
nächstgelegene geplante Vorlagen-Abendzeile des Vortags (17:30–21:30, Vorrang: schon KH-betont,
dann „Abendessen…", dann Nähe zu 19:00) — egal aus welcher Vorlage/Einheit — statt nur an eine
Zeile exakt 19:00; Label wird <Original> · KH-betont (+300 kcal vor dem Long Run), idempotent.
Nur wenn der Vortag keine Abendzeile hat, entsteht die eigene 19:00-Zeile. Keine doppelten
Abendzeilen mehr (Fall 04.09.).
- planned_session_id in POST /api/plan (N14): optional für jede Zeile (Gericht/Artikel/frei,
auch Coach-create); 404, wenn die Einheit nicht dem Nutzer gehört; PlanEntry trägt
planned_session_id für alle Arten (null = ungebunden).
5a. Planen v2 — freie Plan-Mahlzeiten, Bearbeiten, Kopieren (SILV-431, Welle 1)
StratoClaude → MacClaude, 28.08.2026 (Epic SILV-429, Dennis-Lock „Planen v2"). Additiv. Contract-Tests
test_plan_v2.py.
- Herkunft + Schutz (Spalten, additiv im PlanEntry-DTO für ALLE Einträge):
origin: user|template|coach(Bestand: Vorlagen-Zeilentemplate, alles andereuser),user_edited: bool,note: string|null. Fürkind=template_mealistsource=origin(user|template| coach); für Gerichte/Artikel bleibtsourcedie Gericht-Quelle (hellofresh/manual/artikel …) — der Client nimmt dortorigin. - Freie Plan-Mahlzeit:
POST /api/plan {user_id, date, kind:"template_meal", label, time HH:MM, planned_kcal, planned_protein_g?, planned_carbs_g?, planned_fat_g?, meal?, note?, shared?}→{id};origin=user,status=planned, keintemplate_id. Pflicht: label, time, planned_kcal (sonst 400). Zählt wie eine Vorlage:/api/day.planned[]+planned_kcal, Kurve (planned-Sprung mitplanned_meal_id), Ersetzen ±90 min durch Ist-Einträge,/eat. - Slot aus der Uhrzeit (GELOCKT, gleiche Grenzen wie
mealSlotim Client):< 10:30 fruehstueck · < 14:30 mittag · < 17:30 snack · sonst abend(plan_slot_from_time). Gilt für freie Einträge, Generator-Zeilen und beimPATCH timeohne explizitesmeal; ein mitgegebenesmealgewinnt. (Das Tagebuch behält seinen eigenen Default 11/15/17/22 — anderer Vertrag.) - Bearbeiten:
PATCH /api/plan/{id}zusätzlichlabel, time, planned_kcal, planned_protein_g, planned_carbs_g, planned_fat_g, note— nurkind=template_meal(Gericht → 400;servings/ meal/dateweiter für alle). Jede dieser Änderungen setztuser_edited=1;source/originbleiben (eine vom Nutzer bearbeitete Vorlage bleibttemplate). Coach-Bearbeitung (SILV-438): der Coach-MCP setzt nach seinem PATCH percoach-touchorigin=coach(user_editedbleibt 0) — die Zeile ist damit für Generator/Sync unantastbar wieuser_edited, der Client zeigt die Sparkle-Kachel. Jede Bearbeitung (Nutzer/Coach) löschtsession_changed_at. - Löschen = Tombstone (SILV-438):
DELETE /api/plan/{id}einer Generator-Zeile (Spaltetemplate_row_key=template|label|n, auch Vortags-Zeilelong|eve|1) legt einen Tombstone (plan_tombstone: Nutzer, Tag, Schlüssel) an — der Sync-Regenerator (§5d) legt die Zeile nicht wieder an, egal ob Dennis oder der Coach gelöscht hat. Ein explizitertemplate-meals/generate(App „Vorlage übernehmen"/Coachplan_template_generate) setzt die Tombstones des Tages zurück (bewusste Neu-Übernahme;previewzeigt entsprechend alle Zeilen). - DTO-Felder additiv (SILV-438/442):
session_changed_at: ISO|null(der Intervals-Sync hat den Slot des Tages geändert, ohne diese Zeile anzufassen — §5d),outcome: consumed|replaced|discarded|null(Ausgang: gegessen · durch Ist-Eintrag/eatersetzt →replaced_by_entry_id·skipped/expired= verworfen · null = offen). Eine per/eatgegessene Vorlagen-Zeile istreplaced(bestehender Vertrag), ein gegessenes Gerichtconsumed. - Generator (
template-meals/generate) respektiert: er ersetzt nur noch Zeilen mitorigin=template AND user_edited=0 AND status=planned; freie (user), Coach- und bearbeitete Zeilen bleiben stehen und belegen ihr Zeitfenster — Layout-Zeilen innerhalb ±60 min einer behaltenen Zeile werden ausgelassen (keine zweite Mittagsmahlzeit neben der bearbeiteten). Der Vortags-Bonus fasst eine bearbeitete 19:00-Zeile nicht an. - Kopieren:
POST /api/plan/{id}/copy {date, meal?}→ neuer offener Eintrag (origin=user, Vorlagen-Bindung entfällt,notekopiert; HF-Gericht in der Zielwoche → 409).POST /api/plan/copy-day {user_id, from, to, replace?:bool}kopiert alle offenen eigenen Einträge (Gericht/Artikel ungegessen, Plan-Mahlzeitenplanned) →{from, to, ids, deleted, skipped_hf:[Name…]};replace=truelöscht vorher die offenen Einträge des Zieltags (idempotent), ohnereplacewird angehängt; HF-Gerichte, die in der Zielwoche schon liegen, werden übersprungen und gemeldet;from == to→ 400. - Live:
plan_changed(user, date) + Budget-Sync bei jeder Änderung an Plan-Mahlzeiten.
5b. Vorschau + Vorlagen-Katalog (SILV-433, Welle 2)
GET /api/plan/templates→{ templates: [{id, name, description}] }in fester Reihenfolgelong, interval, threshold, easy, strength, rest(Anzeigenamen: Long Run, Intervalle, Schwelle, Easy / Dauerlauf, Kraft, Ruhetag).GET /api/plan/template-meals/preview?user_id&date&template_id?&planned_session_id?→{ date, template_id, layout_name: rest|long_morning|long_late|morning|midday|evening, session: {id, type, slot_start, slot_end, expected_kcal}|null, budget_kcal, protein_goal_g, rows: [{time, label, kcal, protein_g, carbs_g, fat_g, share}], would_replace: [plan_id…], eve_bonus: {date, kcal}|null }Dieselbe Rechenquelle wiegenerate(_template_plan; eine Funktion, zwei Aufrufer):rowssind byte-gleich zu den Einträgen, diegenerateanlegen würde — inklusive der ±60-min-Regel um behaltene Zeilen (§5a). Nichts wird persistiert.would_replace= die unbearbeiteten Generator-Zeilen des Tages, diegenerateersetzen würde (bearbeitete/freie/ Coach-Zeilen stehen nie darin).eve_bonusist nur gesetzt, wenngenerateden Vortags-Bonus (long +300 / threshold +200) anwenden würde; ist er schon angewandt oder die 19:00-Zeile bearbeitet →null. Ohnetemplate_idgilt die Einheit des Tages (long > interval > threshold…), sonst
rest; unbekannte Vorlage → 400.generateliefert additivlayout_name. Optionaltoday=YYYY-MM-DD(Stichtag-Pin, Tests): die Vorschau am Trainingstag vor Slot-Ende ist identisch zur Vorschau am Vorabend (SILV-439,budget_kcalinkl.expected_kcal).
5c. Coach schreibt (SILV-435, Welle 3) — Verweis
Der Coach-MCP v2 schreibt über dieselbe Plan-API (Loopback) mit origin=coach + note;
Einträge mit user_edited=1 fasst er nie an, nur heute/zukünftig, Audit-Log plan_audit
(intern GET /api/plan/audit). Für den Client: Coach-Einträge erkennt man an origin=coach
(+ note als Begründung); bearbeitet der Nutzer sie, werden sie user_edited und damit
coach-unantastbar. Wahrheit: docs/syntheses/coach-mcp.md §9.
5d. Sync-Regel für den Vorlagen-Generator (SILV-438, Coach-Defect D2)
Vorfall 29.08.2026: der 07:50-Intervals-Sync regenerierte zwei Tage und überschrieb vier vom Coach bearbeitete Zeilen (+ zwei Löschungen), weil Coach-Bearbeitungen
origin=templateließen. Seitdem gilt je berührtem Tag ab heute (_sync_plan_day, Worker + Testmodeplan_sync):
- Reiner Vorlagen-Tag (alle offenen Plan-Mahlzeiten
origin=template AND user_edited=0) → regenerieren wie bisher (Tombstones §5a bleiben weg) —"regenerated". - Berührter Tag (mindestens eine offene Plan-Mahlzeit mit
origin ∈ {user, coach}oderuser_edited=1) → nichts ersetzen, nichts anlegen. Hat sich die Einheit des Tages geändert (Ingest-Signatur je Tag: external_id, Slot, Typ, Status, expected —changedin der Ingest-Antwort), werden nurplanned_session_idder Vorlagen-Zeilen auf die Einheit des Tages nachgezogen (null, wenn entfernt) undsession_changed_at(ISO) auf allen offenen Plan-Mahlzeiten des Tages gesetzt —"relinked"; sonst"unchanged". Der Coach siehtsession_changed_atinplan_get/PlanEntry und passt die Zeiten selbst an;expected_kcalder Einheit steht amsession-Objekt (§5e). - Vergangene Tage nie. Gericht-/Artikel-Einträge (
kind=dish) zählen nicht als „berührt" (der Generator hat sie nie angefasst) — nur Plan-Mahlzeiten.
Ein expliziter generate (App/Coach) bleibt davon unberührt: er ersetzt nur unbearbeitete
Generator-Zeilen (would_replace), lässt Coach-/Nutzer-Zeilen stehen (±60-min-Regel) und setzt
Tombstones zurück.
5e. Wochen-DTO — GET /api/plan/week?user_id&from=<datum>[&today=] (SILV-442, Welle 4)
Für den Wochen-Editor (SILV-436) und den Coach (plan_get liest genau dies). from wird auf
den Montag normiert; today pinnt den Stichtag (Tests). MUSS vor /api/plan/{start} liegen.
{ user_id, monday, sunday, today,
days: [{ date,
entries: [PlanEntry… (eigene + shared; mit origin/user_edited/note/outcome/session_changed_at)],
planned_kcal, planned_protein_g, # offene Plan-Einträge (status planned), 1 Portion
kcal_eaten, protein_eaten_g, # Tagebuch
projected_kcal, projected_protein_g, # gegessen + offen (= „Tag laut Plan")
budget_kcal, protein_target_g, # Ziel des Tages (Budget v2; Eiweiß kg × g/kg, sonst users.protein_target_g)
activity_kcal, defizit_kcal,
session: {id, type, slot_start, slot_end, expected_kcal, status, name,
planned_duration_min, template_id} | null, # Einheit des Tages (§5 Wahl)
session_changed_at: ISO|null, # max über die Einträge (§5d)
kpi: {low_kcal, low_at, hours_below_400, end_kcal, end_delta_kcal}, # Kurve MIT Plan (§3)
reference: {floor_kcal, target_end_kcal},
two_day: {dates:[d, d+1], projected_kcal, budget_kcal, delta_kcal} | null }] }
SILV-450 (N11/N12): jeder PlanEntry (auch kind=dish/Artikel) trägt time (HH:MM | null; Kurve
und Zwei-Tage-Spange nehmen die Uhrzeit, Slot-Anker nur als Fallback) und lint[]; jeder Tag trägt
lint[{rule, text, severity}] (Plan-Linter, sieben Regeln, nur heute + Zukunft — Vertrag im
coach-app-vertrag §10). Supplement-Artikel (foods.is_supplement) zählen
nicht in kcal_eaten/protein_eaten_g.
two_day nur an Tagen mit session.type ∈ {long, interval} (harter Tag + Folgetag gegen
2 × Budget — die Regel, nach der der Coach plant; Sonntag ohne Folgetag → null). Die Kurven-
Kennzahlen sind dieselben wie /api/energy/curve (Zukunft = volle Projektion inkl. Plan).
6. Wochen-Rollup (SILV-421) — GET /api/energy/week?user_id[&monday&today]
{ user_id, monday, sunday, today, wochenbilanz_kcal, logged_days, hours_below_400_mean,
protein_goal_g,
days: [{ date, logged, partial, budget_kcal, activity_kcal, intake_kcal, bilanz_vs_budget_kcal,
end_kcal, end_delta_kcal, hours_below_400, low_kcal, session_types: [type…],
two_day_sum_kcal, two_day_target_kcal,
carbs_24h_before_quality_g, carbs_target_g }],
ea: { value, ffm_kg, ffm_source, ffm_date, band: ok|low|critical|null, uncertainty: 4,
days, exercise_kcal_sum } | null }
- Nulls (SILV-428):
days[].hours_below_400=null, wennlogged=false(ein leerer Tag trägt sonst 18–19 bedeutungslose Stunden; der Rollup schloss ihn schon aus, die Tagesliste nicht).ea.band=null, solangedays < 5(Client zeigt dann nur Wert + Tage). -
Eiweiß-Ziel EINE Quelle (SILV-428):
protein_goal_g(hier) undprotein_target_g(/api/daysummary) =protein_g_per_kg-Setting × letztes Gewicht;users.protein_target_gnur noch Fallback ohne Gewichtspunkt (vorher: summary 125 hart vs. week 154). -
Alle 7 Tage werden geliefert (
logged= ≥ 1 Diary-Eintrag,partial= laufender Tag); Rollups (wochenbilanz,logged_days,hours_below_400_mean, EA) nur über abgeschlossene geloggte Tage — der halb geloggte heutige Tag würde EA/Bilanz nach unten ziehen.bilanz_vs_budget = intake − budget. - 2-Tage-Summe (Abweichung 8) an Tagen mit
long/interval:end_kcal(Tag) + end_kcal(Folgetag)gegen−(defizit_Tag + defizit_Folgetag); nur wenn der Folgetag geloggt und ≤ heute ist, sonstnull. - EA = (Σ Aufnahme − Σ Trainings-kcal) / FFM / geloggte Tage; Trainings-kcal = Garmin-
Workouts, HK-Workouts, manuell, eGYM nur gemessen — kein NEAT, keine eGYM-Schätzung.
FFM aus der jüngsten Messung mit Gewicht und KFA (
ffm_source,ffm_date); ohne KFAea=null. Bänder ≥ 35 ok · 30–35 low · < 30 critical, Unsicherheit ±4 — Fakten, keine Wertung. - KH vor Qualitätseinheit (interval/threshold/long, nicht skipped): Σ
carbs_gder Einträge in den 24 h vorslot_startgegen5 g/kg× aktuelles Gewicht. - Protein:
GET /api/protein/goal?user_id→{ goal_g, basis_kg, g_per_kg, ffm_kg, goal_g_ffm_basis }mitg_per_kgaus den Budget-Settings (protein_g_per_kg, Default 1,6). - Coach-MCP:
get_conceptsspricht v2 (Basis/Budget/Boden/Aktivität/Energiekurve/ 2-Tage-Summe/EA, kein Bonus),get_dayliefert additivenergiekurve(kpi/referenz/ ruhe_quelle),get_week_summaryadditivenergie_woche.
7. Live-Signale + Betrieb
- SSE:
day_changedbei jeder Budget-/Aktivitäts-/Plan-Status-Änderung (bestehender Chokepoint),plan_changedbei Vorlagen/Sessions. - Cron: 06:35 intervals-sync (neben 06:15 eGYM / 06:25 Garmin / 03:45 Budget-Freeze).
- Migration (additiv, idempotent,
db.init):budget_settings+3 Spalten,day_budget+7,activities.kcal_measured,meal_plan+11, neue Tabellenplanned_session,intervals_accounts.
8. Testfixtures
Spec §9.1 (Long Run mit Vorlage) und §9.3 (Ruhetag) als Contract-Tests mit ±5 kcal
(test_energy_curve.py), Beispieltage §1.2 (1.870 / 2.370 / 3.020) in test_budget.py,
AC 2/4/5/6/7/8 in den jeweiligen Test-Dateien. Testmode-Helfer: POST /api/test/body-weight,
POST /api/test/intervals-events (roher Intervals-Payload durch denselben Ingest; plan_sync:
true fährt zusätzlich die Sync-Regel §5d wie der Worker, Antwort changed + plan_sync),
POST /api/test/freeze-v1 (eingefrorener v1-Tag wie Prod vor valid_from, SILV-441).
SILV-438/439/440/442 in test_plan_v2.py + test_coach_write.py, SILV-441 in
test_energy_curve.py.