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, "revision": 1,
"source_file": plan["source_file"], "source_file": plan["source_file"],
"source_hash": plan["source_hash"], "source_hash": plan["source_hash"],
"plan_id": plan.get("plan_id"),
"plan_revision": plan.get("published_revision"),
"profile": { "profile": {
"start_date": "", "start_date": "",
"display_name": "", "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) tracker = _read_json(_tracker_path(plan["source_file"]), None)
if not isinstance(tracker, dict): if not isinstance(tracker, dict):
return _default_tracker(plan) return _default_tracker(plan)
original_tracker = deepcopy(tracker)
tracker.setdefault("version", TRACKER_SCHEMA_VERSION) tracker.setdefault("version", TRACKER_SCHEMA_VERSION)
tracker.setdefault("revision", 1) tracker.setdefault("revision", 1)
tracker.setdefault("profile", {}) tracker.setdefault("profile", {})
@@ -1290,7 +1293,18 @@ def _load_tracker(plan: dict[str, Any]) -> dict[str, Any]:
tracker.pop("analyses", None) tracker.pop("analyses", None)
tracker["version"] = TRACKER_SCHEMA_VERSION tracker["version"] = TRACKER_SCHEMA_VERSION
tracker["source_file"] = plan["source_file"] 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 return tracker
@@ -1394,6 +1408,347 @@ def _format_result_data(value: Any) -> str:
return f"{prefix}{joined(data['values'])}{unit}{side}".strip() 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]: def _normalize_tracker_payload(plan: dict[str, Any], incoming: Any, existing: dict[str, Any]) -> dict[str, Any]:
if not isinstance(incoming, dict): if not isinstance(incoming, dict):
raise ValueError("Tracker-Daten müssen ein JSON-Objekt sein") 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()): for week, status in list(clean["week_statuses"].items()):
if not isinstance(status, dict): clean["week_statuses"].pop(week, None); continue if not isinstance(status, dict): clean["week_statuses"].pop(week, None); continue
status["status"] = "closed" if status.get("status") == "closed" else "open" status["status"] = "closed" if status.get("status") == "closed" else "open"
clean, _ = _migrate_tracker_sessions(plan, clean)
return clean return clean
+31 -28
View File
@@ -72,7 +72,7 @@
.save-pill.saved { color: var(--ok); } .save-pill.saved { color: var(--ok); }
.save-pill.error { color: var(--danger); } .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 { .card {
min-width: 0; max-width: 100%; min-width: 0; max-width: 100%;
background: var(--paper); border: 1px solid var(--line); border-radius: var(--radius); 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.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.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); } .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)} .exercise.skipped{opacity:.74}.exercise.partial{border-left-color:var(--accent)}
.skip-reason{margin-top:9px} .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; } .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; } .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 { padding: 13px 14px; border-top: 1px solid var(--line-soft); }
.exercise:first-child { border-top: 0; } .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)); } .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; } .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; } .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); } .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) { @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-controls { grid-template-columns: minmax(0,1fr); }
.result-set-grid { grid-template-columns: repeat(3,minmax(0,1fr)); } .result-set-grid { grid-template-columns: repeat(3,minmax(0,1fr)); }
.result-editor { padding: 10px; } .result-editor { padding: 10px; }
@@ -360,6 +362,7 @@
(() => { (() => {
'use strict'; 'use strict';
const BASE = document.body.dataset.base || ''; const BASE = document.body.dataset.base || '';
const RESULT_DATA_VERSION = 2;
const api = path => `${BASE}${path}`; const api = path => `${BASE}${path}`;
const state = { const state = {
plans: [], selectedId: null, plan: null, tracker: null, capabilities: {openai:false}, plans: [], selectedId: null, plan: null, tracker: null, capabilities: {openai:false},
@@ -923,7 +926,7 @@
weightMode = 'optional'; weightMode = 'optional';
} }
return { 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, weight_mode: weightMode,
default_sets: sets, locked_sets: lockedSets, fixed_interval: Boolean(base.fixed_interval), default_sets: sets, locked_sets: lockedSets, fixed_interval: Boolean(base.fixed_interval),
work_seconds: Number(base.work_seconds) || null, work_seconds: Number(base.work_seconds) || null,
@@ -934,7 +937,7 @@
function emptyResultData(schema, sets = schema.default_sets) { function emptyResultData(schema, sets = schema.default_sets) {
const count = schema.mode === 'none' ? 1 : Math.max(1, Math.min(20, Number(sets) || 1)); const count = schema.mode === 'none' ? 1 : Math.max(1, Math.min(20, Number(sets) || 1));
return { 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', sides_mode: schema.laterality === 'unilateral' ? (schema.sides_mode || 'same') : 'same',
sets: count, weight_kg: null, sets: count, weight_kg: null,
values: Array(count).fill(null), left_values: Array(count).fill(null), right_values: Array(count).fill(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 mode = ['reps','seconds','minutes','none'].includes(source.mode) ? source.mode : schema.mode;
const laterality = ['bilateral','unilateral'].includes(source.laterality) ? source.laterality : schema.laterality; const laterality = ['bilateral','unilateral'].includes(source.laterality) ? source.laterality : schema.laterality;
return { return {
version: 1, mode, laterality, version: RESULT_DATA_VERSION, mode, laterality,
sides_mode: laterality === 'unilateral' && source.sides_mode === 'separate' ? 'separate' : 'same', sides_mode: laterality === 'unilateral' && source.sides_mode === 'separate' ? 'separate' : 'same',
sets, weight_kg: numericOrNull(source.weight_kg), sets, weight_kg: numericOrNull(source.weight_kg),
values: ensureResultArray(source.values, sets), values: ensureResultArray(source.values, sets),
@@ -956,11 +959,11 @@
} }
function parseLegacyResultData(result, schema) { function parseLegacyResultData(result, schema) {
const raw = String(result || '').trim(); 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 low = raw.toLowerCase().replace(/×/g, 'x');
const detectedMode = /(?:\d|\s)(?:min\.?|minuten)\b/i.test(low) ? 'minutes' const explicitMode = /(?:\d|\s)(?:min\.?|minuten)\b/i.test(low) ? 'minutes'
: /(?:\d|\s)(?:s|sek\.?|sekunden)\b/i.test(low) ? 'seconds' : 'reps'; : /(?:\d|\s)(?:s|sek\.?|sekunden)\b/i.test(low) ? 'seconds' : null;
if (schema.mode === 'none' || detectedMode !== schema.mode) return {data:null, incompatible:true}; const detectedMode = explicitMode || (['reps','seconds','minutes'].includes(schema.mode) ? schema.mode : 'reps');
const weightMatch = low.match(/(\d+(?:[.,]\d+)?)\s*kg\b/i); const weightMatch = low.match(/(\d+(?:[.,]\d+)?)\s*kg\b/i);
const weight = weightMatch ? numericOrNull(weightMatch[1]) : null; 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); 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 right = numberList(cleanPart(separate[2]));
const sets = Math.max(left.length, right.length, 1); const sets = Math.max(left.length, right.length, 1);
const data = emptyResultData({...schema,laterality:'unilateral'}, sets); 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); 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 work = cleanPart(raw);
let values = []; let values = [];
@@ -984,21 +990,17 @@
} else { } else {
values = numberList(work); 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); const perSide = /(?:je|pro)\s+seite|\/\s*seite/i.test(low);
if (!perSide && schema.laterality === 'unilateral' && schema.sides_mode === 'separate' const sets = Math.max(1, Math.min(20, values.length || Number(schema.default_sets) || 1));
&& values.length === Math.max(1, Number(schema.default_sets) || 1) * 2) { const data = emptyResultData({...schema,mode:detectedMode,laterality: perSide ? 'unilateral' : schema.laterality,sides_mode:'same'}, sets);
const sets = Math.max(1, Number(schema.default_sets) || 1); data.mode = detectedMode;
const data = emptyResultData({...schema,laterality:'unilateral',sides_mode:'separate'}, sets); data.sides_mode = 'same';
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);
data.weight_kg = weight; data.values = ensureResultArray(values, sets); 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) { function resultEditorModel(exercise, item, progression) {
const recommended = resolvedResultSchema(exercise, progression, item); const recommended = resolvedResultSchema(exercise, progression, item);
@@ -1016,9 +1018,10 @@
const legacy = parseLegacyResultData(item?.result || '', recommended); const legacy = parseLegacyResultData(item?.result || '', recommended);
if (legacy.data) { if (legacy.data) {
const weightMode = numericOrNull(legacy.data.weight_kg) !== null && recommended.weight_mode === 'none' ? 'optional' : recommended.weight_mode; 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) { function formatResultData(data) {
@@ -1113,7 +1116,7 @@
const data = normalizeResultData(model.data, model.schema); const data = normalizeResultData(model.data, model.schema);
mutator(data, model.schema); mutator(data, model.schema);
const normalized = normalizeResultData(data, {...model.schema,mode:data.mode,laterality:data.laterality,default_sets:data.sets}); 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)) { if (normalized.mode !== 'none' && (resultHasValues(normalized) || numericOrNull(normalized.weight_kg) !== null)) {
item.result_data = normalized; item.result_data = normalized;
item.result = formatResultData(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 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")) 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"] == "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"]["weight_kg"] == 4.0
assert item["result_data"]["left_values"] == [9.0, 8.0, 8.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 "Übersprungen" in source
assert "Teilweise" in source assert "Teilweise" in source
assert "eine neue analyse überschreibt die vorherige" in source.lower() 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