diff --git a/plugins/trainingstracker/app.py b/plugins/trainingstracker/app.py index 22f6fd0..2ccd3d8 100644 --- a/plugins/trainingstracker/app.py +++ b/plugins/trainingstracker/app.py @@ -1234,6 +1234,8 @@ def _default_tracker(plan: dict[str, Any]) -> dict[str, Any]: "revision": 1, "source_file": plan["source_file"], "source_hash": plan["source_hash"], + "plan_id": plan.get("plan_id"), + "plan_revision": plan.get("published_revision"), "profile": { "start_date": "", "display_name": "", @@ -1274,6 +1276,7 @@ def _load_tracker(plan: dict[str, Any]) -> dict[str, Any]: tracker = _read_json(_tracker_path(plan["source_file"]), None) if not isinstance(tracker, dict): return _default_tracker(plan) + original_tracker = deepcopy(tracker) tracker.setdefault("version", TRACKER_SCHEMA_VERSION) tracker.setdefault("revision", 1) tracker.setdefault("profile", {}) @@ -1290,7 +1293,18 @@ def _load_tracker(plan: dict[str, Any]) -> dict[str, Any]: tracker.pop("analyses", None) tracker["version"] = TRACKER_SCHEMA_VERSION tracker["source_file"] = plan["source_file"] - tracker["source_changed"] = tracker.get("source_hash") not in (None, plan["source_hash"]) + source_changed = tracker.get("source_hash") not in (None, plan["source_hash"]) + tracker, migrated = _migrate_tracker_sessions(plan, tracker) + if migrated: + tracker["revision"] = int(tracker.get("revision") or 1) + 1 + tracker["updated_at"] = _utc_now() + persisted = deepcopy(tracker) + persisted.pop("source_changed", None) + backup_path = SESSIONS_DIR / ".migration-backups" / f"{plan['source_file']}.pre-result-data-v2.json" + if not backup_path.exists(): + _atomic_json_write(backup_path, original_tracker) + _atomic_json_write(_tracker_path(plan["source_file"]), persisted) + tracker["source_changed"] = source_changed return tracker @@ -1394,6 +1408,347 @@ def _format_result_data(value: Any) -> str: return f"{prefix}{joined(data['values'])}{unit}{side}".strip() +def _item_has_user_data(item: Any) -> bool: + if not isinstance(item, dict): + return False + status = str(item.get("completion_status") or ("completed" if item.get("done") else "planned")) + if status != "planned" or bool(item.get("done")): + return True + if isinstance(item.get("result_data"), dict): + return True + return any(_has_text(item.get(field)) for field in ( + "result", "note", "progression", "exercise_name", "progression_id", "skip_reason" + )) + + +def _merge_migrated_item(legacy: dict[str, Any], stable: dict[str, Any]) -> dict[str, Any]: + """Vereinigt doppelte Legacy-/Stable-ID-Einträge ohne befüllte Werte zu verlieren. + + Ein durch die neue UI angelegter leerer Stable-ID-Platzhalter darf einen + bereits abgeschlossenen Legacy-Eintrag insbesondere nicht wieder auf + ``planned``/``done=false`` zurücksetzen. + """ + merged = deepcopy(legacy) + legacy_status = str(legacy.get("completion_status") or ("completed" if legacy.get("done") else "planned")) + stable_status = str(stable.get("completion_status") or ("completed" if stable.get("done") else "planned")) + for key, value in stable.items(): + if key not in merged: + merged[key] = deepcopy(value) + continue + empty = value is None or value == "" or value == [] or value == {} + if empty: + continue + if key in {"done", "completion_status"} and stable_status == "planned" and legacy_status != "planned": + continue + merged[key] = deepcopy(value) + if stable_status != "planned": + merged["completion_status"] = stable_status + merged["done"] = stable_status in {"completed", "partial"} + elif legacy_status != "planned": + merged["completion_status"] = legacy_status + merged["done"] = legacy_status in {"completed", "partial"} + return merged + + +def _exercise_lookup_for_day(plan: dict[str, Any], day_num: int) -> tuple[dict[str, dict[str, Any]], dict[str, str]]: + by_id: dict[str, dict[str, Any]] = {} + legacy_to_stable: dict[str, str] = {} + day = next((entry for entry in plan.get("days", []) if isinstance(entry, dict) and int(entry.get("num") or 0) == day_num), None) + if not isinstance(day, dict): + return by_id, legacy_to_stable + for rotation in day.get("rotations", []) if isinstance(day.get("rotations"), list) else []: + if not isinstance(rotation, dict): + continue + for exercise in rotation.get("exercises", []) if isinstance(rotation.get("exercises"), list) else []: + if not isinstance(exercise, dict): + continue + stable_id = str(exercise.get("id") or "") + legacy_id = str(exercise.get("legacy_id") or "") + if stable_id: + by_id[stable_id] = exercise + if legacy_id and stable_id: + by_id[legacy_id] = exercise + legacy_to_stable[legacy_id] = stable_id + return by_id, legacy_to_stable + + +def _progression_step_for_item(plan: dict[str, Any], exercise: dict[str, Any], item: dict[str, Any]) -> dict[str, Any] | None: + progression_id = str(item.get("progression_id") or exercise.get("progression_id") or "") + stage = plan.get("stages", {}).get(progression_id) if isinstance(plan.get("stages"), dict) else None + steps = stage.get("steps") if isinstance(stage, dict) and isinstance(stage.get("steps"), list) else [] + wanted_id = str(item.get("progression_step_id") or "") + if wanted_id: + found = next((step for step in steps if isinstance(step, dict) and str(step.get("id") or "") == wanted_id), None) + if found: + return found + wanted_name = _plain_text(item.get("progression") or "").casefold() + if wanted_name: + return next(( + step for step in steps + if isinstance(step, dict) and _plain_text(step.get("name") or "").casefold() == wanted_name + ), None) + return None + + +def _resolved_schema_for_item(plan: dict[str, Any], exercise: dict[str, Any], item: dict[str, Any]) -> dict[str, Any]: + schema = deepcopy(exercise.get("result_schema") if isinstance(exercise.get("result_schema"), dict) else {}) + step = _progression_step_for_item(plan, exercise, item) + if isinstance(step, dict) and isinstance(step.get("result_schema"), dict): + schema.update(step["result_schema"]) + manual = item.get("result_format") + if isinstance(manual, dict): + for key in ("mode", "weight_mode", "laterality", "sides_mode", "sets", "default_sets", "locked_sets"): + if key in manual: + schema[key] = manual[key] + mode = str(schema.get("mode") or "auto") + if mode not in {"auto", "reps", "seconds", "minutes", "none"}: + mode = "auto" + weight_mode = str(schema.get("weight_mode") or "none") + if weight_mode not in {"none", "optional", "required"}: + weight_mode = "none" + laterality = str(schema.get("laterality") or "bilateral") + if laterality not in {"bilateral", "unilateral"}: + laterality = "bilateral" + sides_mode = str(schema.get("sides_mode") or "same") + if sides_mode not in {"same", "separate"}: + sides_mode = "same" + try: + sets = max(1, min(20, int(schema.get("sets") or schema.get("default_sets") or 1))) + except (TypeError, ValueError): + sets = 1 + return { + "mode": mode, + "weight_mode": weight_mode, + "laterality": laterality, + "sides_mode": sides_mode if laterality == "unilateral" else "same", + "default_sets": sets, + "locked_sets": bool(schema.get("locked_sets")), + } + + +def _parse_legacy_result_data(value: Any, schema: dict[str, Any]) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: + """Überführt eindeutige alte Freitextergebnisse verlustfrei in result_data 2. + + Die historische Eingabe ist für die konkrete Session bindend. Weicht ihre + eindeutig erkennbare Messart vom heutigen Plan ab, wird deshalb zusätzlich + ein manueller ``result_format``-Override gespeichert, statt Werte zu verwerfen. + """ + raw = str(value or "").strip() + if not raw: + return None, None + low = raw.lower().replace("×", "x") + explicit_mode: str | None = None + if re.search(r"(?:\d|\s)(?:min\.?|minuten)\b", low, flags=re.I): + explicit_mode = "minutes" + elif re.search(r"(?:\d|\s)(?:s|sek\.?|sekunden)\b", low, flags=re.I): + explicit_mode = "seconds" + recommended_mode = str(schema.get("mode") or "auto") + mode = explicit_mode or (recommended_mode if recommended_mode in {"reps", "seconds", "minutes"} else "reps") + + weight_match = re.search(r"(\d+(?:[.,]\d+)?)\s*kg\b", low, flags=re.I) + weight = _result_number(weight_match.group(1)) if weight_match else None + + def number_list(part: Any) -> list[float | None]: + return [ + number for number in (_result_number(match.group(0)) for match in re.finditer(r"\d+(?:[.,]\d+)?", str(part or ""))) + if number is not None + ] + + def clean_part(part: Any) -> str: + text = re.sub(r"\d+(?:[.,]\d+)?\s*kg\b", " ", str(part or ""), flags=re.I) + return re.sub( + r"\b(?:reps?|wiederholungen|s|sek\.?|sekunden|min\.?|minuten|je seite|pro seite)\b", + " ", text, flags=re.I, + ) + + separate = re.search( + r"\bL(?:inks)?\s*:?\s*([^·;|]+)[·;|]\s*R(?:echts)?\s*:?\s*(.+)$", + raw, flags=re.I, + ) + if separate: + left = number_list(clean_part(separate.group(1))) + right = number_list(clean_part(separate.group(2))) + if not left and not right: + return None, None + sets = max(len(left), len(right), 1) + data = _sanitize_result_data({ + "version": RESULT_DATA_VERSION, + "mode": mode, + "laterality": "unilateral", + "sides_mode": "separate", + "sets": sets, + "weight_kg": weight, + "values": [], + "left_values": left, + "right_values": right, + }) + else: + work = clean_part(raw) + repeated = re.search(r"(\d+(?:[.,]\d+)?)\s*x\s*(\d+(?:[.,]\d+)?)", work, flags=re.I) + if repeated: + count = max(1, min(20, int(float(repeated.group(1).replace(",", "."))))) + repeated_value = _result_number(repeated.group(2)) + values = [repeated_value] * count if repeated_value is not None else [] + else: + values = number_list(work) + if not values and weight is None: + return None, None + per_side = bool(re.search(r"(?:je|pro)\s+seite|/\s*seite", low, flags=re.I)) + laterality = "unilateral" if per_side else str(schema.get("laterality") or "bilateral") + if laterality not in {"bilateral", "unilateral"}: + laterality = "bilateral" + # Eine einzige historische Zahlenreihe ist nie eine belastbare Links-/ + # Rechts-Trennung. Bei einseitigen Übungen bedeutet sie daher "je Seite". + sides_mode = "same" + sets = max(1, min(20, len(values) or int(schema.get("default_sets") or 1))) + data = _sanitize_result_data({ + "version": RESULT_DATA_VERSION, + "mode": mode, + "laterality": laterality, + "sides_mode": sides_mode, + "sets": sets, + "weight_kg": weight, + "values": values, + "left_values": [], + "right_values": [], + }) + if data is None: + return None, None + + actual_weight_mode = str(schema.get("weight_mode") or "none") + if weight is not None and actual_weight_mode == "none": + actual_weight_mode = "optional" + differs = ( + data["mode"] != recommended_mode + or data["laterality"] != str(schema.get("laterality") or "bilateral") + or data["sides_mode"] != str(schema.get("sides_mode") or "same") + or actual_weight_mode != str(schema.get("weight_mode") or "none") + ) + result_format = None + if differs: + result_format = { + "mode": data["mode"], + "laterality": data["laterality"], + "sides_mode": data["sides_mode"], + "weight_mode": actual_weight_mode, + } + return data, result_format + + +def _migrate_tracker_sessions(plan: dict[str, Any], tracker: dict[str, Any]) -> tuple[dict[str, Any], bool]: + """Migriert alle vorhandenen Sessions serverseitig und atomar auf stabile IDs. + + Dadurch hängt die Datenmigration nicht davon ab, welche Session zufällig im + Browser geöffnet oder verändert wurde. Uneindeutige Freitexte bleiben als + ``result`` erhalten; eindeutige Werte werden zusätzlich strukturiert gespeichert. + """ + changed = False + tracker = deepcopy(tracker) + desired_root = { + "version": TRACKER_SCHEMA_VERSION, + "source_file": plan.get("source_file"), + "source_hash": plan.get("source_hash"), + "plan_id": plan.get("plan_id"), + "plan_revision": plan.get("published_revision"), + } + for key, value in desired_root.items(): + if tracker.get(key) != value: + tracker[key] = value + changed = True + sessions = tracker.get("sessions") if isinstance(tracker.get("sessions"), dict) else {} + if tracker.get("sessions") is not sessions: + tracker["sessions"] = sessions + changed = True + + for session_key, session in sessions.items(): + if not isinstance(session, dict): + continue + _, day_num = _session_key_parts(str(session_key)) + if day_num == 9999: + continue + by_id, legacy_to_stable = _exercise_lookup_for_day(plan, day_num) + items = session.get("items") if isinstance(session.get("items"), dict) else {} + if session.get("items") is not items: + session["items"] = items + changed = True + + for legacy_id, stable_id in list(legacy_to_stable.items()): + if legacy_id not in items or legacy_id == stable_id: + continue + legacy_item = items.pop(legacy_id) + if isinstance(legacy_item, dict): + stable_item = items.get(stable_id) + items[stable_id] = _merge_migrated_item( + legacy_item, + stable_item if isinstance(stable_item, dict) else {}, + ) + changed = True + + for item_id, item in list(items.items()): + if not isinstance(item, dict): + continue + exercise = by_id.get(str(item_id)) + status = str(item.get("completion_status") or ("completed" if item.get("done") else "planned")) + if status not in VALID_ITEM_STATUSES: + status = "planned" + if item.get("completion_status") != status: + item["completion_status"] = status + changed = True + done = status in {"completed", "partial"} + if bool(item.get("done")) != done: + item["done"] = done + changed = True + if status != "skipped" and "skip_reason" in item: + item.pop("skip_reason", None) + changed = True + + if isinstance(exercise, dict) and _item_has_user_data(item): + metadata = { + "progression_id": str(item.get("progression_id") or exercise.get("progression_id") or ""), + "exercise_name": _plain_text(item.get("exercise_name") or exercise.get("name") or ""), + } + for key, value in metadata.items(): + if value and item.get(key) != value: + item[key] = value + changed = True + step = _progression_step_for_item(plan, exercise, item) + if isinstance(step, dict) and step.get("id") and not item.get("progression_step_id"): + item["progression_step_id"] = str(step["id"]) + changed = True + + if isinstance(item.get("result_data"), dict): + data = _sanitize_result_data(item.get("result_data")) + if data is not None: + formatted = _format_result_data(data) + if item.get("result_data") != data: + item["result_data"] = data + changed = True + if item.get("result") != formatted: + item["result"] = formatted + changed = True + else: + item.pop("result_data", None) + changed = True + elif isinstance(exercise, dict) and _has_text(item.get("result")): + schema = _resolved_schema_for_item(plan, exercise, item) + data, result_format = _parse_legacy_result_data(item.get("result"), schema) + if data is not None: + item["result_data"] = data + item["result"] = _format_result_data(data) + if result_format is not None: + item["result_format"] = result_format + changed = True + + for key, value in { + "plan_id": plan.get("plan_id"), + "plan_revision": plan.get("published_revision"), + }.items(): + if session.get(key) != value: + session[key] = value + changed = True + return tracker, changed + + def _normalize_tracker_payload(plan: dict[str, Any], incoming: Any, existing: dict[str, Any]) -> dict[str, Any]: if not isinstance(incoming, dict): raise ValueError("Tracker-Daten müssen ein JSON-Objekt sein") @@ -1436,6 +1791,7 @@ def _normalize_tracker_payload(plan: dict[str, Any], incoming: Any, existing: di for week, status in list(clean["week_statuses"].items()): if not isinstance(status, dict): clean["week_statuses"].pop(week, None); continue status["status"] = "closed" if status.get("status") == "closed" else "open" + clean, _ = _migrate_tracker_sessions(plan, clean) return clean diff --git a/plugins/trainingstracker/static/index.html b/plugins/trainingstracker/static/index.html index 4f34840..3476872 100644 --- a/plugins/trainingstracker/static/index.html +++ b/plugins/trainingstracker/static/index.html @@ -72,7 +72,7 @@ .save-pill.saved { color: var(--ok); } .save-pill.error { color: var(--danger); } - .app { width: min(900px, 100%); min-width: 0; max-width: 100%; margin: 0 auto; padding: 12px 10px 104px; } + .app { width: min(1180px, 100%); min-width: 0; max-width: 100%; margin: 0 auto; padding: 12px 10px 104px; } .card { min-width: 0; max-width: 100%; background: var(--paper); border: 1px solid var(--line); border-radius: var(--radius); @@ -117,7 +117,7 @@ .status-badge.in_progress { background: var(--accent-soft); color: var(--accent); } .status-badge.stopped { background: color-mix(in srgb, var(--muted) 13%, var(--paper)); color: var(--muted); } .status-badge.completed { background: color-mix(in srgb, var(--ok) 16%, var(--paper)); color: var(--ok); } - .item-status-select{width:auto;min-width:112px;padding:5px 7px;font-size:11px;font-weight:750} + .item-status-select{width:100%;min-width:0;max-width:132px;padding:5px 7px;font-size:11px;font-weight:750} .exercise.skipped{opacity:.74}.exercise.partial{border-left-color:var(--accent)} .skip-reason{margin-top:9px} .notice { padding: 11px 12px; border-radius: var(--radius-sm); background: var(--accent-soft); border: 1px solid color-mix(in srgb, var(--accent) 34%, var(--line)); font-size: 12px; margin-bottom: 10px; } @@ -139,7 +139,7 @@ .rotation-label { padding: 9px 14px; color: var(--muted); background: var(--wash-2); font-size: 11px; font-weight: 800; letter-spacing: .04em; } .exercise { padding: 13px 14px; border-top: 1px solid var(--line-soft); } .exercise:first-child { border-top: 0; } - .exercise-title { display: grid; grid-template-columns: 28px minmax(0,1fr); gap: 8px; align-items: start; } + .exercise-title { display: grid; grid-template-columns: minmax(112px,132px) minmax(0,1fr); gap: 10px; align-items: start; } .exercise.done { background: color-mix(in srgb, var(--ok) 7%, var(--paper)); } .cue { color: var(--muted); font-size: 12px; line-height: 1.45; margin-top: 4px; } .progression-box { margin-top: 9px; padding: 9px; background: var(--teal-soft); border: 1px solid color-mix(in srgb, var(--teal) 25%, var(--line)); border-radius: var(--radius-sm); font-size: 12px; } @@ -284,6 +284,8 @@ .error-card { border: 1px solid color-mix(in srgb, var(--danger) 55%, var(--line)); background: color-mix(in srgb, var(--danger) 13%, var(--paper)); color: var(--danger); padding: 13px; border-radius: var(--radius); } @media (max-width: 420px) { + .exercise-title { grid-template-columns: minmax(0,1fr); } + .item-status-select { max-width: none; } .result-controls { grid-template-columns: minmax(0,1fr); } .result-set-grid { grid-template-columns: repeat(3,minmax(0,1fr)); } .result-editor { padding: 10px; } @@ -360,6 +362,7 @@ (() => { 'use strict'; const BASE = document.body.dataset.base || ''; + const RESULT_DATA_VERSION = 2; const api = path => `${BASE}${path}`; const state = { plans: [], selectedId: null, plan: null, tracker: null, capabilities: {openai:false}, @@ -923,7 +926,7 @@ weightMode = 'optional'; } return { - version: 1, mode, laterality, sides_mode: laterality === 'unilateral' ? sidesMode : 'same', + version: RESULT_DATA_VERSION, mode, laterality, sides_mode: laterality === 'unilateral' ? sidesMode : 'same', weight_mode: weightMode, default_sets: sets, locked_sets: lockedSets, fixed_interval: Boolean(base.fixed_interval), work_seconds: Number(base.work_seconds) || null, @@ -934,7 +937,7 @@ function emptyResultData(schema, sets = schema.default_sets) { const count = schema.mode === 'none' ? 1 : Math.max(1, Math.min(20, Number(sets) || 1)); return { - version: 1, mode: schema.mode, laterality: schema.laterality, + version: RESULT_DATA_VERSION, mode: schema.mode, laterality: schema.laterality, sides_mode: schema.laterality === 'unilateral' ? (schema.sides_mode || 'same') : 'same', sets: count, weight_kg: null, values: Array(count).fill(null), left_values: Array(count).fill(null), right_values: Array(count).fill(null) @@ -946,7 +949,7 @@ const mode = ['reps','seconds','minutes','none'].includes(source.mode) ? source.mode : schema.mode; const laterality = ['bilateral','unilateral'].includes(source.laterality) ? source.laterality : schema.laterality; return { - version: 1, mode, laterality, + version: RESULT_DATA_VERSION, mode, laterality, sides_mode: laterality === 'unilateral' && source.sides_mode === 'separate' ? 'separate' : 'same', sets, weight_kg: numericOrNull(source.weight_kg), values: ensureResultArray(source.values, sets), @@ -956,11 +959,11 @@ } function parseLegacyResultData(result, schema) { const raw = String(result || '').trim(); - if (!raw) return {data:null, incompatible:false}; + if (!raw) return {data:null, incompatible:false, formatOverride:null}; const low = raw.toLowerCase().replace(/×/g, 'x'); - const detectedMode = /(?:\d|\s)(?:min\.?|minuten)\b/i.test(low) ? 'minutes' - : /(?:\d|\s)(?:s|sek\.?|sekunden)\b/i.test(low) ? 'seconds' : 'reps'; - if (schema.mode === 'none' || detectedMode !== schema.mode) return {data:null, incompatible:true}; + const explicitMode = /(?:\d|\s)(?:min\.?|minuten)\b/i.test(low) ? 'minutes' + : /(?:\d|\s)(?:s|sek\.?|sekunden)\b/i.test(low) ? 'seconds' : null; + const detectedMode = explicitMode || (['reps','seconds','minutes'].includes(schema.mode) ? schema.mode : 'reps'); const weightMatch = low.match(/(\d+(?:[.,]\d+)?)\s*kg\b/i); const weight = weightMatch ? numericOrNull(weightMatch[1]) : null; const numberList = part => [...String(part || '').matchAll(/\d+(?:[.,]\d+)?/g)].map(match => numericOrNull(match[0])).filter(value => value !== null); @@ -971,9 +974,12 @@ const right = numberList(cleanPart(separate[2])); const sets = Math.max(left.length, right.length, 1); const data = emptyResultData({...schema,laterality:'unilateral'}, sets); - data.sides_mode = 'separate'; data.weight_kg = weight; + data.mode = detectedMode; data.sides_mode = 'separate'; data.weight_kg = weight; data.left_values = ensureResultArray(left, sets); data.right_values = ensureResultArray(right, sets); - return {data, incompatible:false}; + const weightMode = weight !== null && schema.weight_mode === 'none' ? 'optional' : schema.weight_mode; + const formatOverride = data.mode !== schema.mode || data.laterality !== schema.laterality || data.sides_mode !== schema.sides_mode || weightMode !== schema.weight_mode + ? {mode:data.mode,laterality:data.laterality,sides_mode:data.sides_mode,weight_mode:weightMode} : null; + return {data, incompatible:false, formatOverride}; } let work = cleanPart(raw); let values = []; @@ -984,21 +990,17 @@ } else { values = numberList(work); } - if (!values.length) return {data:null, incompatible:false}; + if (!values.length && weight === null) return {data:null, incompatible:false, formatOverride:null}; const perSide = /(?:je|pro)\s+seite|\/\s*seite/i.test(low); - if (!perSide && schema.laterality === 'unilateral' && schema.sides_mode === 'separate' - && values.length === Math.max(1, Number(schema.default_sets) || 1) * 2) { - const sets = Math.max(1, Number(schema.default_sets) || 1); - const data = emptyResultData({...schema,laterality:'unilateral',sides_mode:'separate'}, sets); - data.sides_mode = 'separate'; data.weight_kg = weight; - data.left_values = ensureResultArray(values.filter((_, index) => index % 2 === 0), sets); - data.right_values = ensureResultArray(values.filter((_, index) => index % 2 === 1), sets); - return {data, incompatible:false}; - } - const sets = Math.max(1, Math.min(20, values.length)); - const data = emptyResultData({...schema,laterality: perSide ? 'unilateral' : schema.laterality}, sets); + const sets = Math.max(1, Math.min(20, values.length || Number(schema.default_sets) || 1)); + const data = emptyResultData({...schema,mode:detectedMode,laterality: perSide ? 'unilateral' : schema.laterality,sides_mode:'same'}, sets); + data.mode = detectedMode; + data.sides_mode = 'same'; data.weight_kg = weight; data.values = ensureResultArray(values, sets); - return {data, incompatible:false}; + const weightMode = weight !== null && schema.weight_mode === 'none' ? 'optional' : schema.weight_mode; + const formatOverride = data.mode !== schema.mode || data.laterality !== schema.laterality || data.sides_mode !== schema.sides_mode || weightMode !== schema.weight_mode + ? {mode:data.mode,laterality:data.laterality,sides_mode:data.sides_mode,weight_mode:weightMode} : null; + return {data, incompatible:false, formatOverride}; } function resultEditorModel(exercise, item, progression) { const recommended = resolvedResultSchema(exercise, progression, item); @@ -1016,9 +1018,10 @@ const legacy = parseLegacyResultData(item?.result || '', recommended); if (legacy.data) { const weightMode = numericOrNull(legacy.data.weight_kg) !== null && recommended.weight_mode === 'none' ? 'optional' : recommended.weight_mode; - return {schema:{...recommended,weight_mode:weightMode}, data:legacy.data, source:'legacy', incompatible:false}; + const legacySchema = {...recommended,...(legacy.formatOverride || {}),weight_mode:legacy.formatOverride?.weight_mode || weightMode,schema_source:legacy.formatOverride ? 'session' : recommended.schema_source}; + return {schema:legacySchema, data:legacy.data, source:'legacy', incompatible:false, formatOverride:legacy.formatOverride}; } - return {schema:recommended, data:emptyResultData(recommended), source:item?.result ? 'legacy-incompatible' : 'empty', incompatible:legacy.incompatible}; + return {schema:recommended, data:emptyResultData(recommended), source:item?.result ? 'legacy-incompatible' : 'empty', incompatible:legacy.incompatible, formatOverride:null}; } function formatResultData(data) { @@ -1113,7 +1116,7 @@ const data = normalizeResultData(model.data, model.schema); mutator(data, model.schema); const normalized = normalizeResultData(data, {...model.schema,mode:data.mode,laterality:data.laterality,default_sets:data.sets}); - if (storeFormat) item.result_format = {mode: normalized.mode, laterality: normalized.laterality, sides_mode: normalized.sides_mode, weight_mode: model.schema.weight_mode}; + if (storeFormat || model.formatOverride) item.result_format = {mode: normalized.mode, laterality: normalized.laterality, sides_mode: normalized.sides_mode, weight_mode: model.schema.weight_mode}; if (normalized.mode !== 'none' && (resultHasValues(normalized) || numericOrNull(normalized.weight_kg) !== null)) { item.result_data = normalized; item.result = formatResultData(normalized); diff --git a/plugins/trainingstracker/tests/test_app.py b/plugins/trainingstracker/tests/test_app.py index cca414d..5ca83b3 100644 --- a/plugins/trainingstracker/tests/test_app.py +++ b/plugins/trainingstracker/tests/test_app.py @@ -379,8 +379,12 @@ def test_structured_result_is_saved_canonically_and_used_by_analysis(tmp_path: P assert client.put("/api/plans/training.json/tracker", json=tracker).status_code == 200 raw = json.loads((tracker_data / "sessions" / "training.json").read_text(encoding="utf-8")) - item = raw["sessions"]["w01-d01"]["items"]["d1-r0-e0"] + plan = module._normalize_plan(training) + stable_id = plan["days"][0]["rotations"][0]["exercises"][0]["id"] + assert "d1-r0-e0" not in raw["sessions"]["w01-d01"]["items"] + item = raw["sessions"]["w01-d01"]["items"][stable_id] assert item["result"] == "4 kg · L 9/8/8 · R 9/8/9 Reps" + assert item["result_data"]["version"] == 2 assert item["result_data"]["weight_kg"] == 4.0 assert item["result_data"]["left_values"] == [9.0, 8.0, 8.0] @@ -458,3 +462,119 @@ def test_frontend_contains_explicit_week_status_and_item_skip_states(): assert "Übersprungen" in source assert "Teilweise" in source assert "eine neue analyse überschreibt die vorherige" in source.lower() + + +def test_legacy_results_are_migrated_server_side_without_hidden_values(tmp_path: Path): + plans = tmp_path / "data" / "trainingsplan" / "plans" + tracker_data = tmp_path / "data" / "trainingstracker" + plans.mkdir(parents=True) + training = plans / "training.json" + payload = sample_plan() + payload["config"]["days"][0]["rotations"][0]["exercises"][0]["result_schema"] = { + "mode": "reps", "weight_mode": "required", "laterality": "unilateral", + "sides_mode": "separate", "sets": 3, + } + training.write_text(json.dumps(payload), encoding="utf-8") + sessions = tracker_data / "sessions" + sessions.mkdir(parents=True) + (sessions / "training.json").write_text(json.dumps({ + "version": 5, + "revision": 4, + "sessions": { + "w01-d01": { + "status": "completed", + "items": { + "d1-r0-e0": { + "done": True, + "progression": "assistiert", + "result": "4kg, 6/6/5 je Seite", + "note": "Altwert", + } + }, + } + }, + }), encoding="utf-8") + + module = load_module(Path(__file__).parents[1] / "app.py", tracker_data, plans) + client = module.app.test_client() + detail = client.get("/api/plans/training.json").get_json() + plan = detail["plan"] + tracker = detail["tracker"] + stable_id = plan["days"][0]["rotations"][0]["exercises"][0]["id"] + item = tracker["sessions"]["w01-d01"]["items"][stable_id] + + assert "d1-r0-e0" not in tracker["sessions"]["w01-d01"]["items"] + assert item["result_data"] == { + "version": 2, + "mode": "reps", + "laterality": "unilateral", + "sides_mode": "same", + "sets": 3, + "weight_kg": 4.0, + "values": [6.0, 6.0, 5.0], + "left_values": [None, None, None], + "right_values": [None, None, None], + } + assert item["result_format"]["sides_mode"] == "same" + assert item["result"] == "4 kg · 6/6/5 Reps je Seite" + assert tracker["revision"] == 5 + + persisted = json.loads((sessions / "training.json").read_text(encoding="utf-8")) + assert persisted["sessions"]["w01-d01"]["items"][stable_id]["result_data"]["version"] == 2 + backup = tracker_data / "sessions" / ".migration-backups" / "training.json.pre-result-data-v2.json" + assert json.loads(backup.read_text(encoding="utf-8"))["version"] == 5 + assert client.get("/api/plans/training.json").get_json()["tracker"]["revision"] == 5 + + +def test_legacy_mode_override_and_zero_weight_are_preserved(tmp_path: Path): + plans = tmp_path / "plans" + tracker_data = tmp_path / "tracker" + plans.mkdir() + training = plans / "training.json" + training.write_text(json.dumps(sample_plan()), encoding="utf-8") + module = load_module(Path(__file__).parents[1] / "app.py", tracker_data, plans) + + seconds, override = module._parse_legacy_result_data("6x20 s", { + "mode": "reps", "weight_mode": "none", "laterality": "bilateral", + "sides_mode": "same", "default_sets": 6, + }) + assert seconds["mode"] == "seconds" + assert seconds["values"] == [20.0] * 6 + assert override["mode"] == "seconds" + + weighted, override = module._parse_legacy_result_data("0kg, 8/7", { + "mode": "reps", "weight_mode": "required", "laterality": "bilateral", + "sides_mode": "same", "default_sets": 2, + }) + assert weighted["weight_kg"] == 0.0 + assert weighted["values"] == [8.0, 7.0] + assert override is None + + +def test_empty_stable_placeholder_does_not_erase_completed_legacy_item(tmp_path: Path): + plans = tmp_path / "plans" + tracker_data = tmp_path / "tracker" + plans.mkdir() + training = plans / "training.json" + training.write_text(json.dumps(sample_plan()), encoding="utf-8") + module = load_module(Path(__file__).parents[1] / "app.py", tracker_data, plans) + + merged = module._merge_migrated_item( + {"done": True, "completion_status": "completed", "result": "8/7", "note": "wichtig"}, + {"done": False, "completion_status": "planned", "result": "", "note": "", "exercise_name": "Squat"}, + ) + assert merged["done"] is True + assert merged["completion_status"] == "completed" + assert merged["result"] == "8/7" + assert merged["note"] == "wichtig" + assert merged["exercise_name"] == "Squat" + + +def test_frontend_hotfix_uses_result_data_v2_and_non_overlapping_status_layout(): + source = (Path(__file__).parents[1] / "static" / "index.html").read_text(encoding="utf-8") + assert "const RESULT_DATA_VERSION = 2" in source + assert "grid-template-columns: minmax(112px,132px) minmax(0,1fr)" in source + assert "@media (max-width: 420px)" in source + assert ".exercise-title { grid-template-columns: minmax(0,1fr); }" in source + assert ".app { width: min(1180px, 100%)" in source + assert "formatOverride" in source