Zuletzt aktualisiert:

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, Endpoint GET /api/day/{user}/{date}); Contract-Tests app/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):

Invariante (Anti-Drift)

Summe der in activities zurückgegebenen kcal == 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_energy in app/backend/main.py, Tabelle health_energy_hourly + db.upsert_energy_hourly; Contract-Tests app/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 garmingarmin). 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):

  1. 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.)
  2. Jedes Sample anteilig allokieren: kcal × (gewonnene Dauer / eigene Dauer); je Quelle aufsummieren.
  3. 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 benannte garmin-Zeile.
  4. Apple-Rest = gewonnene watch/iphone/other-Energie (je eigene Zeile).
  5. Display-Zeilen in activities materialisieren (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_energy v2 + _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:

  1. garmin-direkt (activities.source='garmin' + Fenster aus garmin_activities started_at+duration_sec).
  2. Client-HK-Workouts — neues Feld workouts[] in POST /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.
  3. eGYM-Sessions (activities.source='egym' + Fenster aus egym_visits visited_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 + Tabelle health_resting_energy_sample; _best_source_energy_total / _verbrauch_by_day + verbrauch_kcal in diary_range; Contract-Tests app/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/syncidentisches 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.