From 314f4f329e5abd0a0383fa97a5c8bff5fa23482c Mon Sep 17 00:00:00 2001 From: AI Agent Date: Mon, 5 Oct 2026 14:23:46 +0000 Subject: [PATCH] tools/: Screenshot-Skript (headless Chromium) + Demo-Generator dauerhaft ablegen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tools/screenshot.sh: konsolidiert (--view today|history, --size, --out, --full); startet eigenen Server mit temporaerem Zustand, befuellt eine Session bzw. importiert Demo-Daten, schießt den Screenshot, raeumt auf - tools/gen_demo.py: Demo-Daten-Generator (Pfade relativ zum Repo) - tools/README.md: Nutzung --- tools/README.md | 33 ++++++++ tools/gen_demo.py | 179 ++++++++++++++++++++++++++++++++++++++++++++ tools/screenshot.sh | 95 +++++++++++++++++++++++ 3 files changed, 307 insertions(+) create mode 100644 tools/README.md create mode 100755 tools/gen_demo.py create mode 100755 tools/screenshot.sh diff --git a/tools/README.md b/tools/README.md new file mode 100644 index 0000000..12fe663 --- /dev/null +++ b/tools/README.md @@ -0,0 +1,33 @@ +# tools + +Helfer für Entwicklung und Doku. Nicht Teil der App. + +## `gen_demo.py` + +Erzeugt `demo-data.json` (12 Wochen realistischer Verlauf: Plateaus, +Deload-Woche, ausgelassene Tage, Streuung, Notizen). + +```bash +python3 tools/gen_demo.py +``` + +## `screenshot.sh` + +Screenshot der App per headless Chromium (für README/Doku). Startet einen +eigenen Server auf einem temporären Zustand, befüllt eine Beispiel-Session +oder importiert die Demo-Daten, schießt den Screenshot und räumt auf. + +```bash +# Heute-Ansicht, alle Übungen befüllt (für README) +tools/screenshot.sh --full --out assets/screenshot.png + +# Verlauf mit Demo-Daten +tools/screenshot.sh --view history --out /tmp/history.png + +# Handy-Ansicht +tools/screenshot.sh --size 390x844 --out /tmp/mobile.png +``` + +Optionen: `--view today|history`, `--size WxH`, `--out PFAD`, `--full`. + +Voraussetzung: `chromium` (oder `chromium-browser`), `python3`, `curl`. \ No newline at end of file diff --git a/tools/gen_demo.py b/tools/gen_demo.py new file mode 100755 index 0000000..a0b701e --- /dev/null +++ b/tools/gen_demo.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +"""Erzeugt demo-data.json (trainingsplan-export-v2) mit 12 Wochen realistischem +Verlauf: Plateau-Phasen, Deload-Woche, ausgelassene Tage, Reps-Streuung, +gelegentlich nicht abgehakte Saetze und Notizen - statt glattgebuegelter +Steigerung.""" +import json +import os +import random +from datetime import date, timedelta + +BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +CFG = json.load(open(f"{BASE}/config.json", encoding="utf-8")) +gym = next(a for a in CFG["activities"] if a["id"] == "gym") + +rng = random.Random(42) + +WEEK_COUNT = 12 +WEEKS = ["Push", "Pull", "Legs"] + + +def rep_value(target): + if not target: + return "10" + lo = int(target.split("-")[0]) + hi = int(target.split("-")[1]) if "-" in target else lo + return (lo + hi) // 2 + + +def week_dates(): + """Montag/Mittwoch/Freitag; die letzte Woche endet VOR heute (alles Vergangenheit).""" + today = date.today() + last_mon = today - timedelta(days=(today.weekday() % 7) + 7) + out = [] + for week in range(WEEK_COUNT): + base = last_mon - timedelta(weeks=WEEK_COUNT - 1 - week) + out.append([base, base + timedelta(days=2), base + timedelta(days=4)]) + return out + + +def set_weight(base, session_idx, week_idx): + """Realistische Gewichtskurve: Plateau-Phasen + Deload in Woche 8.""" + w = base + progress = session_idx // 2 # alle zwei Sessions +1 Stufe + # Uebung-spezifische Rate: grosse Lifts 2.5er-Schritte, kleine 1.25er + step = 2.5 if base >= 20 else 1.25 + w += step * progress + # Plateau: bei manchen Sessions bleibt das Gewicht trotz Fortschritt + if rng.random() < 0.18: + w -= step + # Deload-Woche (Woche 8, idx 7): -12% + if week_idx == 7: + w = round(w * 0.88 / step) * step + return max(0, round(w / step) * step) + + +def build_gym_session(sid, d, day_key, day_cfg, session_idx, week_idx, note): + entries = [] + for ex in day_cfg["exercises"]: + base = float(ex.get("startKg") or 0) + w = set_weight(base, session_idx, week_idx) + cfg = ex["sets"] + count = len(cfg) if isinstance(cfg, list) else int(cfg) + if week_idx == 7: + count = max(1, count - 1) # Deload: ein Satz weniger + reps_t = rep_value(ex.get("reps") or "") + for i in range(count): + reps = max(4, reps_t + rng.choice([-2, -1, 0, 0, 0, 1])) + done = 1 + weight = f"{w:g}" + # Letzter Satz haengt mal (weniger Reps), selten ein Satz ausgelassen + if i == count - 1 and rng.random() < 0.15: + reps = max(3, reps - 2) + if rng.random() < 0.05: + done = 0 + entries.append({ + "exercise": f"{day_key}/{ex['name']}", "sort": i, + "weight": weight, "reps": str(reps), "done": done, + }) + return { + "id": sid, "activity": "gym", "day": day_key, + "date": d.isoformat(), + "start_time": rng.choice(["17:45", "18:00", "18:15", "19:00"]), + "end_time": rng.choice(["18:50", "19:10", "19:20", "20:05"]), + "note": note, "fields": {}, "status": "done", "entries": entries, + } + + +def build_run(sid, d, idx): + km = round(rng.uniform(4.0, 7.2), 1) + pace = rng.uniform(5.9, 7.1) # min/km + pm, ps = int(pace), int(round((pace - int(pace)) * 60)) + minutes = round(km * pace) + return { + "id": sid, "activity": "run", "day": "", "date": d.isoformat(), + "start_time": rng.choice(["07:00", "07:15", "18:30"]), + "end_time": "08:00", "note": rng.choice(["", "", "Lockerer Lauf", + "Tempo-Runde", "Regen"]), + "fields": { + "distanceKm": f"{km:.1f}", + "minutes": str(minutes), + "pacePerKm": f"{pm}:{ps:02d}", + "heartRate": str(rng.randint(140, 166)), + "elevation": str(rng.randint(10, 55)), + }, + "status": "done", "entries": [], + } + + +def build_ride(sid, d, idx): + km = rng.randint(14, 30) + speed = rng.uniform(20.5, 25.5) + minutes = round(km / speed * 60) + return { + "id": sid, "activity": "ride", "day": "", "date": d.isoformat(), + "start_time": "09:00", "end_time": "10:00", + "note": rng.choice(["", "Ausfahrt", "Mit Gegenwind"]), + "fields": { + "distanceKm": str(km), "minutes": str(minutes), + "avgSpeed": f"{speed:.1f}", + "heartRate": str(rng.randint(132, 158)), + "elevation": str(rng.randint(45, 130)), + }, + "status": "done", "entries": [], + } + + +def main(): + dates = week_dates() + days = {d["key"]: d for d in gym["cycle"]["days"]} + sessions = [] + sid = 1 + notes = { + "Push": ["", "", "Brust fiel schwer", "Fokus Form, Kontrolle", ""], + "Pull": ["", "Ruecken verkrampft", "", "Zugkraft gut", ""], + "Legs": ["", "", "Deload: leichter", "Beine schwer, trotzdem durch", ""], + } + skipped = {(2, 0), (5, 2), (7, 0), (10, 1)} # Woche, Tag-Index (Mo/Di/Fr=0/1/2) + for week in range(WEEK_COUNT): + for di, day_key in enumerate(WEEKS): + if (week, di) in skipped: + continue + d = dates[week][di] + note = rng.choice(notes[day_key]) + sid_s = 0 + for s in sessions: + if s["day"] == day_key: + sid_s += 1 + sessions.append(build_gym_session(sid, d, day_key, days[day_key], + sid_s, week, note)) + sid += 1 + # Cardio: Mo abends laufen, So manchmal Fahrt + sessions.append(build_run(sid, dates[week][0] + timedelta(days=0), week)) + sid += 1 + if rng.random() < 0.3: + sessions.append(build_ride(sid, dates[week][2] + timedelta(days=2), week)) + sid += 1 + + doc = { + "format": "trainingsplan-export-v2", + "exported_at": date.today().isoformat() + "T00:00:00", + "config": CFG, + "sessions": sessions, + } + out = f"{BASE}/demo-data.json" + with open(out, "w", encoding="utf-8") as f: + json.dump(doc, f, ensure_ascii=False, indent=1) + acts = {} + for s in sessions: + acts[s["activity"]] = acts.get(s["activity"], 0) + 1 + print(f"{out}: {len(sessions)} Sessions, {acts}") + print("Zeitraum:", min(s["date"] for s in sessions), "bis", + max(s["date"] for s in sessions)) + notes_n = sum(1 for s in sessions if s["note"]) + undone = sum(1 for s in sessions for e in s["entries"] if not e["done"]) + print("mit Notiz:", notes_n, "| nicht abgehakte Saetze:", undone) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tools/screenshot.sh b/tools/screenshot.sh new file mode 100755 index 0000000..66c93cb --- /dev/null +++ b/tools/screenshot.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# Screenshot der App fuer Doku/README (headless Chromium). +# +# Nutzung: +# tools/screenshot.sh [--view today|history] [--size 1280x1080] +# [--out PFAD] [--full] +# +# --view today (Standard, mit Beispiel-Session) oder history (Demo-Verlauf) +# --size Fenstergroesse, z.B. 390x844 fuer die Handy-Ansicht +# --out Zieldatei (Standard: tools/screenshot.png) +# --full heutige Ansicht mit ALLEN Uebungen befuellt (nicht nur ein paar) +# +# Voraussetzungen: python3, curl, chromium (oder chromium-browser). +set -euo pipefail + +BASE="$(cd "$(dirname "$0")/.." && pwd)" +VIEW=today +SIZE=1280x1080 +OUT="$BASE/tools/screenshot.png" +FULL=0 +PORT=8097 + +while [ $# -gt 0 ]; do + case "$1" in + --view) VIEW="$2"; shift 2;; + --size) SIZE="$2"; shift 2;; + --out) OUT="$2"; shift 2;; + --full) FULL=1; shift;; + *) echo "unbekannte Option: $1" >&2; exit 1;; + esac +done + +STATE="$(mktemp -d)" +TRAINING_STATE_DIR="$STATE" PORT="$PORT" python3 "$BASE/server.py" >"$STATE/log" 2>&1 & +SRV=$! +trap 'kill "$SRV" 2>/dev/null || true; rm -rf "$STATE"' EXIT +sleep 1.2 +B="http://127.0.0.1:$PORT" + +if [ "$VIEW" = "history" ]; then + curl -s -X PUT "$B/api/settings" -H 'Content-Type: application/json' \ + -d '{"setupDone":true,"startTab":"history"}' >/dev/null + python3 - "$B" "$BASE" <<'PY' +import json, sys, urllib.request +B, base = sys.argv[1], sys.argv[2] +demo = json.load(open(base + "/demo-data.json", encoding="utf-8")) +data = json.dumps({"format": "trainingsplan-export-v2", + "data": json.dumps(demo)}).encode() +urllib.request.urlopen(urllib.request.Request( + B + "/api/import", data=data, + headers={"Content-Type": "application/json"}, method="POST")).read() +PY +else + curl -s -X PUT "$B/api/settings" -H 'Content-Type: application/json' \ + -d '{"setupDone":true}' >/dev/null + python3 - "$B" "$FULL" <<'PY' +import json, sys, urllib.request +B, full = sys.argv[1], sys.argv[2] == "1" +sid = json.load(urllib.request.urlopen(urllib.request.Request( + B + "/api/sessions", + data=json.dumps({"activity": "gym", "day": "Push"}).encode(), + headers={"Content-Type": "application/json"}, method="POST")))["id"] +small = { + "Push/Bankdrücken KH (je Hand)": [("18,5", "10"), ("18,5", "10"), ("20", "8"), ("20", "6")], + "Push/Schrägbank KH (je Hand)": [("14", "12"), ("14", "10")], + "Push/Pectoral/Butterfly": [("20", "15")], +} +big = { + "Push/Bankdrücken KH (je Hand)": [("18,5", "10"), ("18,5", "10"), ("20", "8"), ("20", "6")], + "Push/Schrägbank KH (je Hand)": [("14", "12"), ("14", "10"), ("14", "10")], + "Push/Pectoral/Butterfly": [("20", "15"), ("20", "15"), ("25", "12")], + "Push/Seitheben (je Hand)": [("8", "15"), ("8", "14"), ("8", "12")], + "Push/Schulterpresse Maschine": [("25", "12"), ("30", "10"), ("30", "9")], + "Push/Kabel-Trizeps-Pushdown": [("25", "15"), ("25", "14"), ("27,5", "12")], + "Push/Trizeps-Strecken (French Press Maschine)": [("15", "15"), ("15", "12"), ("15", "12")], + "Push/Kabel-Curls (Bizeps)": [("15", "15"), ("15", "14"), ("17,5", "12")], +} +data = big if full else small +sets = [{"exercise": ex, "sort": i, "weight": w, "reps": r, "done": 1} + for ex, lst in data.items() for i, (w, r) in enumerate(lst)] +urllib.request.urlopen(urllib.request.Request( + B + "/api/sessions/" + str(sid), data=json.dumps({"sets": sets}).encode(), + headers={"Content-Type": "application/json"}, method="PATCH")).read() +PY +fi + +CHROME="$(command -v chromium || command -v chromium-browser)" +WS="${SIZE/x/,}" +"$CHROME" --headless=new --no-sandbox --disable-gpu --hide-scrollbars \ + --virtual-time-budget=6000 --window-size="$WS" --screenshot="$OUT" "$B/" \ + >/dev/null 2>&1 || \ +"$CHROME" --headless --no-sandbox --disable-gpu --hide-scrollbars \ + --virtual-time-budget=6000 --window-size="$WS" --screenshot="$OUT" "$B/" \ + >/dev/null 2>&1 +echo "geschrieben: $OUT"