Update version to 0.8.141 and enhance training plan template handling
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m13s

- Incremented app version to 0.8.141 and updated build date to 2026-05-14.
- Modified the planning module version to 0.12.0, improving template section handling with phase metadata.
- Introduced new functions for normalizing and inserting training plan template sections, ensuring accurate phase representation during saves.
- Updated frontend components to utilize new utility functions for managing training plan templates, enhancing user experience and data integrity.
This commit is contained in:
Lars 2026-05-16 07:41:08 +02:00
parent 79e748b470
commit c3eb5a62c4
5 changed files with 282 additions and 58 deletions

View File

@ -0,0 +1,8 @@
-- Vorlagen: Phasen/Parallel-Streams wie im Einheiten-Editor (planLoc-Abbild)
ALTER TABLE training_plan_template_sections
ADD COLUMN IF NOT EXISTS phase_kind VARCHAR(20) NOT NULL DEFAULT 'whole_group',
ADD COLUMN IF NOT EXISTS phase_order_index INT NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS parallel_stream_order_index INT NULL;
COMMENT ON COLUMN training_plan_template_sections.parallel_stream_order_index IS
'NULL = Ganzgruppen-Abschnitt; 0..n = Stream innerhalb paralleler Phase';

View File

@ -1523,32 +1523,187 @@ def _insert_sections_from_legacy_exercises(cur, unit_id: int, exercises_in: List
_insert_section_items(cur, sid, filtered, start_order=0)
def _instantiate_from_template(cur, unit_id: int, template_id: int):
_clear_unit_plan_content(cur, unit_id)
pid = _ensure_default_whole_group_phase(cur, unit_id, order_index=0)
def _normalize_training_plan_template_section_payload(sec: Any, si: int) -> Dict[str, Any]:
title = (sec.get("title") or "").strip() or f"Abschnitt {si + 1}"
order_ix = sec.get("order_index")
if order_ix is None:
order_ix = si
try:
order_ix = int(order_ix)
except (TypeError, ValueError):
order_ix = si
pk = str(sec.get("phase_kind") or "whole_group").strip().lower()
if pk not in ("whole_group", "parallel"):
pk = "whole_group"
try:
p_oi = int(sec.get("phase_order_index") if sec.get("phase_order_index") is not None else 0)
except (TypeError, ValueError):
p_oi = 0
p_so: Optional[int] = None
if pk == "parallel":
raw_so = sec.get("parallel_stream_order_index")
try:
p_so = int(raw_so) if raw_so is not None and raw_so != "" else 0
except (TypeError, ValueError):
p_so = 0
return {
"title": title,
"order_index": order_ix,
"guidance_text": sec.get("guidance_text"),
"phase_kind": pk,
"phase_order_index": p_oi,
"parallel_stream_order_index": p_so,
}
def _insert_training_plan_template_sections(cur, template_id: int, sections_in: List[Any]) -> None:
for si, sec in enumerate(sections_in):
row = _normalize_training_plan_template_section_payload(sec, si)
cur.execute(
"""
INSERT INTO training_plan_template_sections (
template_id, order_index, title, guidance_text,
phase_kind, phase_order_index, parallel_stream_order_index
) VALUES (%s, %s, %s, %s, %s, %s, %s)
""",
(
template_id,
row["order_index"],
row["title"],
row["guidance_text"],
row["phase_kind"],
row["phase_order_index"],
row["parallel_stream_order_index"],
),
)
def _template_rows_to_phases_payload(rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Flache Vorlagen-Sektionen → `phases`-Liste wie beim Training-Unit PUT (nur Gliederung, leere items)."""
if not rows:
return []
phases_out: List[Dict[str, Any]] = []
i = 0
n = len(rows)
while i < n:
r0 = rows[i]
pk0 = str(r0.get("phase_kind") or "whole_group").strip().lower()
if pk0 not in ("whole_group", "parallel"):
pk0 = "whole_group"
try:
p_oix0 = int(r0.get("phase_order_index") if r0.get("phase_order_index") is not None else 0)
except (TypeError, ValueError):
p_oix0 = 0
run: List[Dict[str, Any]] = []
while i < n:
r = rows[i]
pk = str(r.get("phase_kind") or "whole_group").strip().lower()
if pk not in ("whole_group", "parallel"):
pk = "whole_group"
try:
p_oix = int(r.get("phase_order_index") if r.get("phase_order_index") is not None else 0)
except (TypeError, ValueError):
p_oix = 0
if pk != pk0 or p_oix != p_oix0:
break
run.append(r)
i += 1
if pk0 == "whole_group":
secs = []
for j, rr in enumerate(run):
tid = rr.get("id")
secs.append(
{
"title": rr.get("title"),
"order_index": j,
"guidance_notes": rr.get("guidance_text"),
"items": [],
**(
{"source_template_section_id": int(tid)}
if tid is not None
else {}
),
}
)
phases_out.append(
{
"phase_kind": "whole_group",
"order_index": p_oix0,
"title": None,
"guidance_notes": None,
"sections": secs,
}
)
else:
by_stream: Dict[int, List[Dict[str, Any]]] = {}
for rr in run:
raw_so = rr.get("parallel_stream_order_index")
try:
so = int(raw_so) if raw_so is not None and raw_so != "" else 0
except (TypeError, ValueError):
so = 0
by_stream.setdefault(so, []).append(rr)
stream_order = sorted(by_stream.keys())
streams = []
for so in stream_order:
bucket = by_stream[so]
st: Dict[str, Any] = {
"order_index": so,
"title": None,
"notes": None,
"sections": [],
}
for j, rr in enumerate(bucket):
tid = rr.get("id")
st["sections"].append(
{
"title": rr.get("title"),
"order_index": j,
"guidance_notes": rr.get("guidance_text"),
"items": [],
**(
{"source_template_section_id": int(tid)}
if tid is not None
else {}
),
}
)
streams.append(st)
phases_out.append(
{
"phase_kind": "parallel",
"order_index": p_oix0,
"title": None,
"guidance_notes": None,
"streams": streams,
}
)
return phases_out
def _instantiate_from_template(
cur,
unit_id: int,
template_id: int,
*,
profile_id: int,
role: str,
unit_created_by: int,
) -> None:
cur.execute(
"""
SELECT id, title, guidance_text
SELECT id, title, guidance_text, order_index, phase_kind, phase_order_index, parallel_stream_order_index
FROM training_plan_template_sections
WHERE template_id = %s
ORDER BY order_index
""",
(template_id,),
)
rows = cur.fetchall()
for gi, row in enumerate(rows):
r = r2d(row)
cur.execute(
"""
INSERT INTO training_unit_sections (
training_unit_id, phase_id, parallel_stream_id, order_index, title, guidance_notes, source_template_section_id
) VALUES (%s, %s, NULL, %s, %s, %s, %s)
""",
(unit_id, pid, gi, r["title"], r["guidance_text"], r["id"]),
)
# Fallback: keine Sektionen in Vorlage → ein leerer Block
rows_raw = cur.fetchall()
rows = [r2d(r) for r in rows_raw]
if not rows:
_clear_unit_plan_content(cur, unit_id)
pid = _ensure_default_whole_group_phase(cur, unit_id, order_index=0)
cur.execute(
"""
INSERT INTO training_unit_sections (
@ -1557,6 +1712,18 @@ def _instantiate_from_template(cur, unit_id: int, template_id: int):
""",
(unit_id, pid),
)
return
phases_payload = _template_rows_to_phases_payload(rows)
_clear_unit_plan_content(cur, unit_id)
_replace_unit_phases(
cur,
unit_id,
phases_payload,
profile_id,
role,
unit_created_by,
)
def _fetch_training_plan_template_row(cur, tid: int) -> Dict[str, Any]:
@ -1674,18 +1841,7 @@ def create_training_plan_template(data: dict, tenant: TenantContext = Depends(ge
(club_id, profile_id, name, data.get("description"), visibility),
)
tid = cur.fetchone()["id"]
for si, sec in enumerate(sections_in):
title = (sec.get("title") or "").strip() or f"Abschnitt {si + 1}"
order_ix = sec.get("order_index")
if order_ix is None:
order_ix = si
cur.execute(
"""
INSERT INTO training_plan_template_sections (template_id, order_index, title, guidance_text)
VALUES (%s, %s, %s, %s)
""",
(tid, order_ix, title, sec.get("guidance_text")),
)
_insert_training_plan_template_sections(cur, tid, sections_in)
conn.commit()
return get_training_plan_template(tid, tenant)
@ -1743,18 +1899,7 @@ def update_training_plan_template(template_id: int, data: dict, tenant: TenantCo
"DELETE FROM training_plan_template_sections WHERE template_id = %s", (template_id,)
)
sections_in = data["sections"] or []
for si, sec in enumerate(sections_in):
title = (sec.get("title") or "").strip() or f"Abschnitt {si + 1}"
order_ix = sec.get("order_index")
if order_ix is None:
order_ix = si
cur.execute(
"""
INSERT INTO training_plan_template_sections (template_id, order_index, title, guidance_text)
VALUES (%s, %s, %s, %s)
""",
(template_id, order_ix, title, sec.get("guidance_text")),
)
_insert_training_plan_template_sections(cur, template_id, sections_in)
conn.commit()
return get_training_plan_template(template_id, tenant)
@ -2400,7 +2545,14 @@ def create_training_unit(data: dict, tenant: TenantContext = Depends(get_tenant_
elif sections_in is not None:
_replace_unit_sections(cur, unit_id, sections_in)
elif tpl_id_safe:
_instantiate_from_template(cur, unit_id, tpl_id_safe)
_instantiate_from_template(
cur,
unit_id,
tpl_id_safe,
profile_id=profile_id,
role=role,
unit_created_by=profile_id,
)
elif exercises_in is not None:
_insert_sections_from_legacy_exercises(cur, unit_id, exercises_in)
@ -2576,7 +2728,14 @@ def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depen
cur.execute(
"UPDATE training_units SET plan_template_id = %s WHERE id = %s", (tid, unit_id)
)
_instantiate_from_template(cur, unit_id, tid)
_instantiate_from_template(
cur,
unit_id,
tid,
profile_id=profile_id,
role=role,
unit_created_by=int(unit_row.get("created_by") or profile_id),
)
content_handled = True
_assert_single_plan_content_key_update(data)
@ -2783,7 +2942,14 @@ def quick_create_training_unit(data: dict, tenant: TenantContext = Depends(get_t
unit_id = cur.fetchone()["id"]
if tpl_id_safe:
_instantiate_from_template(cur, unit_id, tpl_id_safe)
_instantiate_from_template(
cur,
unit_id,
tpl_id_safe,
profile_id=profile_id,
role=role,
unit_created_by=profile_id,
)
_promote_private_exercises_used_in_unit(cur, unit_id, profile_id, role)
conn.commit()

View File

@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.140"
BUILD_DATE = "2026-05-12"
DB_SCHEMA_VERSION = "20260515063"
APP_VERSION = "0.8.141"
BUILD_DATE = "2026-05-14"
DB_SCHEMA_VERSION = "20260515064"
MODULE_VERSIONS = {
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
@ -24,7 +24,7 @@ MODULE_VERSIONS = {
"exercises": "2.28.0", # GET /api/exercises Keyset cursor_updated_at + cursor_id; Sortierung id als Tie-break
"training_units": "0.3.0", # GET /api/training-units Keyset cursor_planned_date + cursor_id (+ optional cursor_planned_time); Sort mit id-Tiebreak
"training_programs": "0.1.0",
"planning": "0.11.0", # PUT/POST training_units: phases (parallel streams); Rahmen→Termin-Kopie _replace_unit_phases; apply-training-module phase_order_index + parallel_stream_order_index
"planning": "0.12.0", # Trainingsvorlagen: Phasen/Streams in template_sections (064); Instantiate über _replace_unit_phases
"dashboard": "1.1.0", # GET /api/dashboard/kpis inkl. training_home (ein Client-Roundtrip für KPIs + nächste Termine)
"training_modules": "1.0.0",
"import_wiki": "1.0.0",
@ -36,6 +36,13 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.141",
"date": "2026-05-14",
"changes": [
"DB 064: Vorlagen-Sektionen mit phase_kind / phase_order_index / parallel_stream_order_index; Speichern und Anwenden behält Split-Sessions; Server: Vorlage → Einheit über Phasen-Replace.",
],
},
{
"version": "0.8.140",
"date": "2026-05-14",

View File

@ -19,6 +19,8 @@ import {
buildPlanPayloadForSave,
hydrateExercisePlanningRow,
insertTrainingModuleIntoPlanningSections,
templateSectionsPayloadFromFormSections,
formSectionsFromPlanTemplateRows,
} from '../../utils/trainingUnitSectionsForm'
import {
addDaysIsoDate,
@ -549,12 +551,8 @@ function TrainingPlanningPageRoot() {
setFormData((fd) => ({
...fd,
sections: (tpl.sections || []).length
? tpl.sections.map((s) => ({
title: s.title,
guidance_notes: s.guidance_text || '',
items: []
}))
: [defaultSection()]
? formSectionsFromPlanTemplateRows(tpl.sections)
: [defaultSection()],
}))
} catch (err) {
toast.error('Vorlage laden: ' + err.message)
@ -651,10 +649,7 @@ function TrainingPlanningPageRoot() {
try {
await api.createTrainingPlanTemplate({
name: name.trim(),
sections: formData.sections.map((s) => ({
title: s.title || 'Abschnitt',
guidance_text: s.guidance_notes?.trim() ? s.guidance_notes.trim() : null
}))
sections: templateSectionsPayloadFromFormSections(formData.sections),
})
await loadPlanTemplates()
toast.success('Vorlage gespeichert.')

View File

@ -1161,6 +1161,54 @@ export function buildPlanPayloadForSave(sections) {
return buildPhasesPayloadFromFlat(list)
}
/** Payload-Zeilen für POST/PUT /api/training-plan-templates (inkl. Split-/Phasen-Metadaten). */
export function templateSectionsPayloadFromFormSections(sections) {
const norm = inheritPlanLocForPhasedSave(Array.isArray(sections) ? sections : [])
return norm.map((s, si) => {
const canon = canonicalPlanLocForPhasedSave(s.planLoc)
const pk = canon?.phaseKind === 'parallel' ? 'parallel' : 'whole_group'
const poi = canon?.phaseOrderIndex ?? 0
const pso = canon?.phaseKind === 'parallel' ? (canon.parallelStreamOrderIndex ?? 0) : null
return {
order_index: si,
title: (s.title || '').trim() || `Abschnitt ${si + 1}`,
guidance_text: s.guidance_notes?.trim() ? s.guidance_notes.trim() : null,
phase_kind: pk,
phase_order_index: poi,
parallel_stream_order_index: pso,
}
})
}
/** GET-Vorlage → Editor-Abschnitte mit planLoc (Split-Sessions). */
export function formSectionsFromPlanTemplateRows(templateSections) {
const rows = Array.isArray(templateSections) ? [...templateSections] : []
rows.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
if (!rows.length) return [defaultSection()]
return rows.map((s) => {
const pk = String(s.phase_kind || 'whole_group').toLowerCase().trim()
const poiRaw = s.phase_order_index
const poi = poiRaw == null || poiRaw === '' ? 0 : Number(poiRaw)
const phaseOrderIndex = Number.isFinite(poi) ? poi : 0
const soRaw = s.parallel_stream_order_index
let planLoc
if (pk === 'parallel') {
const so = soRaw == null || soRaw === '' ? 0 : Number(soRaw)
planLoc = {
...defaultPlanLocParallel(phaseOrderIndex, Number.isFinite(so) ? so : 0),
}
} else {
planLoc = { ...defaultPlanLocWholeGroup(phaseOrderIndex) }
}
return {
title: s.title || 'Abschnitt',
guidance_notes: s.guidance_text || '',
items: [],
planLoc,
}
})
}
/** Fügt die Positionen eines Moduls in lokale Abschnitte ein (wie eine Übung, ohne Zwischenspeichern der Einheit). */
export async function insertTrainingModuleIntoPlanningSections({
sections,