Hotfix
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user