Tagebuch-Aktivitäten — Konsolidierung (Vertrag, live)
StratoClaude → MacClaude, 22.06.2026 (SILV-322, baut auf SILV-313 „Messung verdrängt Schätzung"). Antwort auf die Backend-Bestellung über den Agent-Bus. Der Server erzeugt die Bedeutung, der Client rendert nur (Anti-Drift). Code-belegt:
app/backend/main.py(_consolidated_activities, EndpointGET /api/day/{user}/{date}); Contract-Testsapp/tests/contract/test_activity_consolidation.py.
Problem
GET /api/day lieferte die Aktivitäten roh. Eine eGYM-Trainingssession
erschien dadurch doppelt: einmal als generische apple_health-Aktivenergie
(die Messung) und einmal als egym-Zeile (die eGYM-Schätzung). Das Budget
zählte über _EGYM_DISPLACED_SQL (SILV-313) korrekt nur die Messung — das
Display aber beide → optische Doppelung (z. B. 592 + 561 = 1.153 statt 592).
Regel (gelockt, Dennis 22.06. — Variante A: pro echter Aktivität EINE Zeile)
Pro (user, date):
- Liegt eine
apple_health-Aktivenergie und eineegym-Session vor, misst dieapple_health-Zeile dieselbe Session (eGYM hat nur geschätzt). Dann wird die generischeapple_health-Zeile in die eGYM-Zeile verschmolzen: - eGYM behält die Identität:
name,emoji,source="egym",ext_ref(= visit_id, für das Client-Mapping auf die Körper-Welt). kcal= der gemessene Apple-Health-Wert (Messung schlägt Schätzung).- additiv:
kcal_source="apple_health"undestimate_kcal(= die verworfene eGYM-Schätzung, rein informativ). - Die separate
apple_health-Zeile entfällt. - Garmin (
source="garmin", mitext_url), manuelle Aktivitäten und jede sonstige Zeile bleiben eigene Zeilen, unverändert. - Ohne
apple_healthan dem Tag bleibt die eGYM-Zeile roh (eigene Schätzung, keinkcal_source).
Invariante (Anti-Drift)
Summe der in
activitieszurückgegebenenkcal==summary.kcal_activity(das Budget,_activity_kcal).
Damit kann das Display nie mehr von der Budget-Wahrheit abweichen. Die Konsolidierung verschiebt nur Identität + kcal-Quelle der gezählten Energie, sie ändert die Summe nicht.
Aktiv-Energie-Reconciliation (SILV-372, live — ersetzt die „1 Tageszahl/Quelle"-Annahme)
StratoClaude → MacClaude, 23.06.2026 (Strato-Teil von SILV-370). Modell GELOCKT von Dennis. Code:
_reconcile_active_energy+HealthSyncIn.active_energyinapp/backend/main.py, Tabellehealth_energy_hourly+db.upsert_energy_hourly; Contract-Testsapp/tests/contract/test_health_energy.py.
Problem: Garmin schreibt Lauf + Alltag zusätzlich als Quelle
com.garmin.connect.mobile mit Zeitfenstern in Apple Health. Die alte Annahme
„apple_health = eine Tagessumme je Quelle" (SILV-355) ließ damit das
Garmin-Connect-Bündel und das Apple-Geräte-Bündel desselben Tags beide
zählen → Doppelung (Dennis 23.06.: 651 + 680 = 1331 statt ~680), zwei
apple_health-Display-Zeilen.
Neue Datenform (Client → Server, Dennis-Entscheid 23.06.: minuten-genau, KEINE
Stunden-Buckets): Aktiv-Energie kommt als rohe Sample-Intervalle je Quelle über
POST /api/health/sync im Feld active_energy[]:
{ ext_ref: "<HealthKit-Sample-UUID>", start: ISO8601, end: ISO8601,
kcal: >0, source_kind: "garmin"|"watch"|"iphone"|"other", bundle: "<HK-Bundle>" }
source_kind klassifiziert der Client zuverlässig (HKSource/productType; Bundle
enthält garmin → garmin). Idempotenz über ext_ref = HealthKit-Sample-UUID
(kein synthetischer Schlüssel mehr). Das lokale Datum (Europe/Berlin) leitet der
Server aus start ab. Roh-Ablage in health_energy_sample. Der Client sendet
nur kcal>0 und bei jedem Sync den ganzen heutigen Tag (alle Quellen/Samples).
Speicherung: roh nur ein Eintrag je HealthKit-Sample (eine Handvoll/Tag/Quelle, NICHT pro Minute); im Tagebuch nur die aggregierten Display-Zeilen je Quelle (Garmin-Alltag / Apple Watch / iPhone / Apple Health). „Minuten-genau" meint die Mathematik (exakte Intervallgrenzen), nicht die Speichergranularität.
Reconciliation (exakte Intervall-Verschneidung, läuft bei JEDEM Sample-Ingest des Tags komplett neu):
- Aus allen Sample-Grenzen Breakpoints bilden; je Elementar-Teilintervall
gewinnt die höchstpriore anwesende Quelle —
garmin > watch > iphone > other. NIE über Quellen summieren; die unterlegene wird im Überlappungsbereich anteilig verdrängt. (Gleiche Quelle, die sich selbst überlappt: das Teilintervall wird gleich auf die beteiligten Samples geteilt → mirror-sicher.) - Jedes Sample anteilig allokieren:
kcal × (gewonnene Dauer / eigene Dauer); je Quelle aufsummieren. - Garmin-Alltag = gewonnene Garmin-Energie minus die direkt gepullten
Garmin-Workout-kcal des Tags (
activities.source='garmin', =active_calories), clamp ≥ 0. Der Lauf bleibt seine eigene benanntegarmin-Zeile. - Apple-Rest = gewonnene
watch/iphone/other-Energie (je eigene Zeile). - Display-Zeilen in
activitiesmaterialisieren (source='apple_health'→ die eGYM-Verdrängung SILV-313 greift weiter) und die alten ERSETZEN — sowohl die Legacy-Tageszeilen (apple_health:active:…ohne Sample) als auch die zuletzt materialisierten (apple_health:reconciled:<kind>:<date>).
Garantie (rückwirkendes Re-Voting, Dennis-Lock): Bucht tagsüber nur das iPhone die Zeit und liefert Garmin abends dasselbe Fenster nach, gewinnt Garmin es beim nächsten Ingest rückwirkend zurück — die iPhone-kcal dieses Fensters fallen anteilig raus. Das leistet das Neu-Verschneiden + Neu-Materialisieren pro Ingest (nicht die ext_ref-Idempotenz allein), weil der Client den ganzen Tag voll nachsendet.
Display-Namen (Server-geliefert): Garmin (Alltag) ⌚️ · Apple Watch ⌚️ ·
iPhone-Bewegung 📱 · Apple Health 🍎.
Invariante bleibt: SUM(activities des Tags) == _activity_kcal (Budget) ==
Summe der gewonnenen Stunden + unveränderte Garmin-Workout-Zeilen.
By-Window-Modell: Workouts[] + eGYM-Fenster (SILV-374, live)
Erweitert SILV-372 zum Window-Claimer-Modell (Modell GELOCKT, Dennis 23.06.). Code:
_reconcile_active_energyv2 +_measured_intervals,health_workout,WorkoutIn,_EGYM_DISPLACED_SQL/_consolidated_activities-Diskriminator.
Claimer = Workout/Session mit Zeitfenster. Jeder beansprucht die gemessene
Energie in seinem Fenster aus der Timeline; der Rest je Quelle = „Alltag". kcal =
gemessen im Fenster, Schätzung/active_kcal nur Fallback ohne Messung:
- garmin-direkt (
activities.source='garmin'+ Fenster ausgarmin_activitiesstarted_at+duration_sec). - Client-HK-Workouts — neues Feld
workouts[]inPOST /api/health/sync:{ ext_ref(HK-UUID), type(running|walking|hiking|cycling|strength|hiit|swimming| yoga|other), start ISO, end ISO, active_kcal, source_kind, date? }. Eigene benannte Zeile je Typ. Dedup gegen garmin-direkt (Zeit-Überlappung + kcal-Nähe) — derselbe Garmin-Lauf kommt via Connect→Health UND via garmin.py, zählt nur EINMAL. Der Client lässt eigene Echos (silverscale_origin= eGYM→Health) aus → eGYM allein aus egym.py. - eGYM-Sessions (
activities.source='egym'+ Fenster ausegym_visitsvisited_at+duration_sec) → die eGYM-Zeile trägt die im Gym-Fenster gemessene Energie statt der Schätzung (Dennis: „am Ende eGYM-Eintrag mit garmin>egym-kcals").
eGYM-Verdrängung BY-DAY → BY-WINDOW: _EGYM_DISPLACED_SQL + die _consolidated-
Verschmelzung greifen nur noch für alte Tage OHNE health_energy_sample-Zeilen
(Historie unberührt). Neue Tage sind schon korrekt materialisiert (eGYM = gemessen,
Alltag = Rest), keine Verdrängung mehr nötig.
⚠️ Zeitzonen (SILV-320): visited_at ist UTC-naiv, garmin started_at
(startTimeLocal) Berlin-naiv, Client-Sample/Workout-Zeiten offset-behaftet (ISO-UTC).
_parse_iso(s, naive_tz=…) parst je Quelle richtig — sonst liegt das Fenster 2 h daneben.
⚠️ REPLACE-PER-DAY statt Upsert-per-UUID (Gerätetest 23.06.): HealthKit/Garmin
reissued für dieselbe Energie bei jedem Sync neue Sample-UUIDs → idempotenz-
per-UUID dedupt das NICHT, überlappende Duplikate akkumulierten (Dennis: 1380 statt
680 roh). Da der Client den ganzen Tag pro Sync sendet, ersetzt der Server die im
Payload enthaltenen Tage komplett (delete+insert je health_energy_sample /
health_workout). Das ist der Dedup. (Folge für Tests/Client: ein Sync MUSS alle
Quellen des Tages enthalten, nicht inkrementell.)
Ruhe-Energie + echter Tages-Verbrauch (SILV-405 / SILV-404, live)
StratoClaude → MacClaude, 03.08.2026. Backend-Bestellung aus SILV-404 (Bilanz: Aufnahme vs. gemessenem Verbrauch statt Ziel/TDEE). Code:
HealthSyncIn.resting_energy+db.upsert_resting_energy_sample+ Tabellehealth_resting_energy_sample;_best_source_energy_total/_verbrauch_by_day+verbrauch_kcalindiary_range; Contract-Testsapp/tests/contract/test_health_resting.py. Sub-Ticket SILV-405.
Neue Datenform (Client → Server): zusätzlich zu active_energy[] sendet der Client
RUHE-Energie (HKQuantityType(.basalEnergyBurned)) im Feld resting_energy[] an
POST /api/health/sync — identisches Shape wie active_energy ({ ext_ref, start,
end, kcal>0, source_kind, bundle, date? }), REPLACE-PER-DAY, Idempotenz über ext_ref.
Scope-Bump v4-resting-energy (User muss HealthKit re-autorisieren). Antwortfeld
resting_synced (additiv, analog energy_synced).
Eigene Quelle, NIE gemischt: Ruhe-Energie liegt in eigener Tabelle
health_resting_energy_sample — bewusst getrennt, weil _reconcile_active_energy die
ganze Aktiv-Tabelle liest; Basalwerte dort würden als Bewegung fehlinterpretiert
(massive Doppelzählung). Kein Reconcile/keine activities-Zeile — Ruhe-Energie ist
keine Aktivität, nur die Basis des Tages-Verbrauchs.
Echter Tages-Verbrauch = bester-Quelle-Ruhe + bester-Quelle-Aktiv: je Tag läuft für
beide Tabellen derselbe Window-Claimer (_measured_intervals, garmin>watch>iphone>
other, nie summieren); die zwei Bestquellen-Summen werden addiert. Ruhe und Aktiv
verschneiden sich nicht gegenseitig (getrennte Quellen/Tabellen), auch bei
Zeit-Überlappung.
Auslieferung: additives, nullable Feld verbrauch_kcal (Double) je Eintrag von
GET /api/diary/range/{user} (geladen in einem Range-Scan, nicht N×) — und
(SILV-406, Nachzug 03.08.) additiv in day_summary(), also konsistent auch in
GET /api/day/{user}/{date}, GET /api/bootstrap und allen Diary-Mutationsantworten
(POST/PATCH/DELETE /api/diary, Aktivitäten, …). day_summary() ist der EINE zentrale
Summary-Builder für all diese Pfade — beim ersten Wurf (SILV-405) war nur der separate
Range-Loop mitgezogen worden, das war der Bug hinter SILV-406. GELOCKT (serverseitige
Bedeutung, Anti-Drift): verbrauch_kcal ist nur dann gesetzt, wenn für den Tag
RUHE-Daten vorliegen — Ruheenergie (~1500+ kcal) ist die unverzichtbare Basis; ohne
sie kein ehrlicher Gesamtverbrauch → null (der Client schweigt, kein
Fallback auf TDEE/Ziel/Rateversuch). Tag mit Ruhe- aber ohne Aktiv-Energie → Ruhe + 0.
Aufnahme-Einfärbung (SILV-407, A1-Gerätetest, 03.08.): die geplante Client-Einfärbung
der vorderen Aufnahmezahl (Aufnahme vs. verbrauch_kcal, abhängig von Zielrichtung +
Deadband) ist reine Client-Anzeigelogik — der Server liefert dafür nur zusätzlich
zielrichtung + deadband_kcal additiv in derselben _budget_dto(), s.
budget-vertrag.md. Kein neues Feld hier, kein Farb-Feld serverseitig.
Datenrealität (eGYM-Verschmelzung — warum 1:1, kein Heuristik-Raten)
Pro Tag gibt es max. 1 eGYM-Session. Liegt an dem Tag eine apple_health-
Aktivenergie vor (jetzt: die oben reconcilten Zeilen), misst sie dieselbe Session
→ 1:1-Verschmelzung wie oben, kein zeit-/dauerbasiertes Matching. (Mehrere
eGYM-Sessions/Tag wären der einzige Sonderfall; dann trägt die erste die gemessene
Energie, die weiteren 0, sodass die Identität erhalten bleibt und die Invariante
hält.) Edge eGYM und Garmin-Alltag am selben Tag: die Verschmelzung labelt dann
auf eGYM — selten, Budget-Summe bleibt korrekt.
Geltungsbereich
Nur GET /api/day/{user}/{date} (Display). diary_range und die Budget-Pfade
liefern ohnehin nur Summen über _EGYM_DISPLACED_SQL und waren schon korrekt.
Das Client-Mapping (eGYM → Körper-Visit via ext_ref, Garmin → ext_url,
apple_health → Health) macht MacClaude; der Server liefert nur saubere source +
ext_ref/ext_url + die Konsolidierung.