This commit is contained in:
2026-07-25 21:10:53 +02:00
parent e5bbc25c89
commit 6dd827b0b4
3 changed files with 509 additions and 30 deletions
+357 -1
View File
@@ -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
+31 -28
View File
@@ -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);
+121 -1
View File
@@ -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