title: Körper-Welt Backend — Verträge (SILV-301/302/307) + eGYM/Garmin-VERIFY category: dev date: 2026-06-18
Körper-Welt Backend (Phase 0)
Drei additive Read-Endpoints + eine neue Tabelle. Alle Bewertungs-Logik (Trend,
PR, TDEE) ist serverseitig — der Client zeigt nur tone/text/verdict,
textet und schwellt NICHTS (Anti-Drift). Bei zu wenig Daten: ehrlich null
(Client zeigt dann kein Verdikt/keinen Wert), nie geraten.
VERIFY-Ergebnisse (an echten Accounts, 18.06.)
Garmin VO2Max/Ruhe-HR — verfügbar ✅.
garminconnect.get_max_metrics(cdate) liefert generic.vo2MaxPreciseValue
(z. B. 48.1), get_rhr_day(cdate) liefert
allMetrics.metricsMap.WELLNESS_RESTING_HEART_RATE[0].value. Beide sind Tages-
Calls (kein Range) — der Sync sampelt sie daher NUR an Trainings-/Aktivitäts-
Tagen (+ heute), nicht 900 Tage einzeln. VO2Max ändert sich ohnehin nur nach
qualifizierenden Aktivitäten.
eGYM Satz-Zeitstempel — NICHT vorhanden ❌.
Die eGYM-Workout-Rohdaten tragen einen Zeitstempel nur pro Übung
(exercise.completedAt), NICHT pro Satz. Ein Set-Objekt hat:
setType, numberOfReps, weight, duration, heartRate, speed, incline,
recoveryTime, trainingMethod — kein eigenes Datum. Folge: eine satz-genaue
Puls-Overlay über Zeitstempel ist NICHT möglich. (Jeder Satz trägt aber eine
eigene duration und ein heartRate-Feld — eine satz-näherungsweise HR-Sicht
wäre darüber denkbar, ohne exakte Uhrzeit.)
Tabelle wellness_metrics (SILV-301)
wellness_metrics(id, user_id, recorded_at, type, value, source, ext_id,
UNIQUE(user_id, type, source, ext_id))
type:VO2MAX|RESTING_HRsource:garmin|egym|apple_healthext_id: stabiler synthetischer Schlüssel je Quelle/Tag (garmin:vo2max:DATE,garmin:rhr:DATE,egym:bio:DATE, Apple = HealthKit-UUID)- Schein-Verlauf-Schutz: der Writer (
db.wellness_record) legt einen neuen Punkt nur an, wenn sich der Wert vom jüngsten Punkt derselben (user,type,source) unterscheidet (|Δ| > 0.05). Re-Sync mit identischem Wert (typisch für den seltenen eGYM-Studio-Wert) erzeugt KEINEN täglichen Flach-Stapel.
GET /api/wellness/{type}
{type} ∈ vo2max | resting_hr (sonst 404). Query: user_id, days=365.
{
"type": "VO2MAX",
"points": [{"recorded_at": "...", "value": 48.1, "source": "garmin", "is_manual": false}, ...],
"verdict": {"tone": "gut", "text": "Über deinem Schnitt, steigend",
"direction": "steigend", "level": "über"} ,
"latest": 48.1
}
verdict= Band-Satz, nie die nackte Zahl als Bewertung.< 2 Punkte → null.is_manual(SILV-317): von Hand getippte Werte (eGYM-source=MANUAL, z. B. ein selbst eingetragener VO2Max) sind als Punkt sichtbar, fließen aber NICHT ins Trend-Verdikt und nicht inlatest— sie würden den gemessenen Verlauf verfälschen.latest= jüngster gemessener Wert.tone:gut|neutral|achtung(semantisch fürs Einfärben). Richtungs-Bedeutung ist metrik-abhängig: VO2Max steigend =gut, Ruhe-HR steigend =achtung(niedriger ist besser).direction:steigend|fallend|stabil;level:über|unter|im(jüngster Wert gg. eigenem Schnitt).
GET /api/egym/machine/{activity_id}/volume (SILV-302)
Query: user_id, days=365. 404 bei unbekanntem Gerät.
{
"activity_id": 998, "label": "EGYM Brustpresse",
"points": [{"visit_id": 1, "date": "2026-06-13", "volume_kg": 660.0,
"top_set": 55.0, "reps": 24}, ...],
"verdict": {"is_pr": true, "tone": "gut",
"verdict_text": "Neue Bestleistung an EGYM Brustpresse: 660 kg bewegt"}
}
volume_kg= Σ(weight × reps) je Trainingseinheit, nurREGULAR_REPBASED.- PR-Verdikt auf der JÜNGSTEN Einheit.
is_prtrue nur, wenn:volume > max(letzte 5 Einheiten) × 1.02UNDvolume − max ≥ 50 kgUNDreps ≥ reps_vorige_Einheit. Server formuliertverdict_text(nur bei PR). < 3 vergleichbare Einheiten → verdict null(Schweigen, ehrlich).- Kalibrierung (echte Daten): über 474 reale bewertbare Einheiten feuert der PR in 11 % — kein Rausch-Dauerfeuer, echte Sprünge.
GET /api/activity/tdee (SILV-307)
Query: user_id, weeks=4. Gemessenes Erhaltungs-Budget (Adaptive TDEE).
{"tdee": 2130, "avg_intake": 1981, "weight_trend_kg_per_week": -0.14,
"span_days": 18, "weeks": 3, "logged_days": 16, "weight_points": 12}
- Energiebilanz:
tdee = Ø-Aufnahme − Gewichts-Trend × 7700 kcal/kg. Der Gewichts-Trend integriert ALLE Aktivität automatisch — Aktivitäts-kcal werden NICHT separat addiert (sonst Doppelzählung). Deterministisch, kein KI. - Konsistenz-Fenster: Ø-Aufnahme UND Gewichts-Trend laufen über DENSELBEN Zeitraum (verankert an [erster..letzter geloggter Aufnahme-Tag]). Sonst verzerrt ein langer Gewichts-Trend bei kurzem Aufnahme-Fenster das Ergebnis.
- Ehrlichkeits-Schwellen:
< 14 geloggte TageoderSpanne < 14 Tageoder< 2 Gewichtsmessungen→tdee: null+reason. Gewicht: FITHUB bevorzugt, Garmin-Waage als Fallback. Client zeigt~+ „Stand: N Wochen".
Apple-Health-Wellness-Ingest (progressiv)
POST /api/health/sync nimmt zusätzlich wellness: [{ext_ref, recorded_at, type,
value}] (additiv, progressiv — Client liefert es, sobald er HealthKit liest).
Idempotent über ext_ref (HealthKit-UUID); Antwort trägt wellness_synced.
GET /api/body/headline (SILV-123)
Query: user_id. EIN server-formulierter Hero-Satz zur Verfassung + tone.
{"text": "Leichter — dein Defizit wirkt", "tone": "gut"}
- Deterministisch, kein KI. Wählt das salienteste ehrliche Signal aus
Gewichts-/Muskel-/VO2Max-Trend (35-Tage-Fenster):
|Änderung| / Band, größter Ausschlag gewinnt; Gleichstand-Priorität Gewicht > Muskel > VO2Max. Bänder: Gewicht 0.8 kg · Muskel 0.4 kg · VO2Max 1.0. - Sätze: leichter → „Leichter — dein Defizit wirkt" (wenn gemessenes Defizit
greift, SILV-307) / „Leichter als zuletzt" (sonst),
gut; schwerer → „Etwas schwerer als zuletzt",neutral; Muskel↑ → „Stärker als zuletzt — mehr Muskel",gut; Muskel↓ → „Etwas Muskel verloren",achtung; VO2Max↑/↓ → „Ausdauernder als zuletzt"gut/ „Kondition etwas gesunken"achtung. - Kein Signal über Band, aber Daten da → „Stabil gehalten",
neutral. Zu wenig Daten (Trends außerhalb des Fensters) →{"text": null}(Client schweigt, zeigt nur Avatar/neutralen Titel). - Dennis-Lock: NIEMALS „X Jahre jünger" — die Web-Bio-Age-Trophäe portiert NICHT.
GET /api/protein/goal (SILV-305)
Query: user_id. Tägliches Eiweiß-Ziel aus dem aktuellen Körpergewicht.
{"goal_g": 125, "basis_kg": 78.3, "g_per_kg": 1.6}
- Faktor 1.6 g/kg (evidenzbasiert, ISSN 1.4–2.0 g/kg für Aktive; 1.6 als
Default zum Muskelerhalt im Kaloriendefizit).
basis_kg/g_per_kgfür Transparenz. Kein Gewicht hinterlegt →{"goal_g": null}. - Gewicht = jüngste Messung (beste Quelle). Die Wochen-Bilanz (Protein vs.
Ziel) rechnet der Client aus
/api/diary/range(proteinG/Tag) × Ziel — kein Aggregat-Endpoint nötig. - ⚠ Hinweis: bei sehr hohem Körperfettanteil ist
×Gesamtgewichttendenziell hoch; eine spätere Verfeinerung auf Magermasse/Zielgewicht wäre ein Folge-Ticket.
GET /api/body/recomp (SILV-306)
Query: user_id. Body-Recomposition-Verdikt, FITHUB-only (gleiche Messmethode
— nie Garmin↔FITHUB, ADR-30/Methoden-Rauschen).
{"text": "Gewicht stabil — Muskel +0,6 kg, Fett −0,5 kg", "tone": "gut",
"muscle_delta": 0.6, "fat_delta": -0.5, "window_days": 47}
- Feuert nur, wenn Gewicht ~stabil (|Δ| ≤ 1.0 kg über 60-Tage-Fenster, ≥21 d
Spanne) UND Muskel/Fett gegensätzlich auseinanderlaufen (|Δ| ≥ 0.4 kg bei
mindestens einem). Sonst
{"text": null}(eine Gewichts-Story erzählt der Hero, SILV-123 — keine Doppelung). tone:gut(Muskel↑/Fett↓ = echte Rekomposition) |achtung(Muskel↓/Fett↑).- Deltas im Text mit deutschem Komma + Vorzeichen; numerisch zusätzlich als Felder.
Offen (eigenes Ticket)
kcal-Ehrlichkeit (gemessen verdrängt eGYM-Schätzung, Dämpfung des Fallbacks) = SILV-313 (Sub von SILV-121) — kalibrier-gated, NICHT hier.