Implement Phase C2 Enhancements for Exercise Suggestions
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 40s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 18s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m14s
Test Suite / pytest-backend (pull_request) Successful in 38s
Test Suite / lint-backend (pull_request) Successful in 0s
Test Suite / build-frontend (pull_request) Successful in 13s
Test Suite / k6 /health Baseline (pull_request) Successful in 33s
Test Suite / playwright-tests (pull_request) Successful in 1m13s
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 40s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 18s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m14s
Test Suite / pytest-backend (pull_request) Successful in 38s
Test Suite / lint-backend (pull_request) Successful in 0s
Test Suite / build-frontend (pull_request) Successful in 13s
Test Suite / k6 /health Baseline (pull_request) Successful in 33s
Test Suite / playwright-tests (pull_request) Successful in 1m13s
- Incremented version to 0.8.184, reflecting the implementation of Phase C2 features. - Added support for displaying variant lists and suggested variant names in exercise suggestions. - Enhanced the ExercisePickerModal to allow selection of exercise variants and improved handling of variant IDs. - Updated backend logic to enrich planning hits with variant metadata, ensuring accurate exercise variant selection. - Documented changes in the changelog to highlight the new capabilities in planning AI functionality.
This commit is contained in:
parent
b2157d8a40
commit
a34e748be5
|
|
@ -189,7 +189,7 @@ Wenn `hits` leer oder Trainer wählt „Mit KI anlegen“:
|
||||||
| **A** | Voll-Library Hybrid-Ranking | ✅ **0.8.177** |
|
| **A** | Voll-Library Hybrid-Ranking | ✅ **0.8.177** |
|
||||||
| **B** | Text-Signale guidance/Rahmen-Ziele | ✅ **0.8.181** |
|
| **B** | Text-Signale guidance/Rahmen-Ziele | ✅ **0.8.181** |
|
||||||
| **C1** | Graph auto-match + variantenbewusste Nachfolger | ✅ **0.8.183** |
|
| **C1** | Graph auto-match + variantenbewusste Nachfolger | ✅ **0.8.183** |
|
||||||
| **C2** | Varianten in Trefferliste / Picker | 🔲 |
|
| **C2** | Varianten in Trefferliste / Picker | ✅ **0.8.184** |
|
||||||
| **C3** | Graph-Builder (Ziel → Pfad → speichern) | 🔲 |
|
| **C3** | Graph-Builder (Ziel → Pfad → speichern) | 🔲 |
|
||||||
| **D** | Neu-Anlage: Pack an `suggestExerciseAi` | 🔲 |
|
| **D** | Neu-Anlage: Pack an `suggestExerciseAi` | 🔲 |
|
||||||
|
|
||||||
|
|
@ -210,7 +210,7 @@ Wenn `hits` leer oder Trainer wählt „Mit KI anlegen“:
|
||||||
|
|
||||||
- **Ungespeicherte Plan-Änderungen:** ✅ Client übergibt `planned_exercise_ids[]` aus Formular (TrainingUnitEditPage).
|
- **Ungespeicherte Plan-Änderungen:** ✅ Client übergibt `planned_exercise_ids[]` aus Formular (TrainingUnitEditPage).
|
||||||
- **Progressionsgraph-ID:** ✅ Auto-Match vom Anker (**C1**); manuelle Auswahl in UI noch offen.
|
- **Progressionsgraph-ID:** ✅ Auto-Match vom Anker (**C1**); manuelle Auswahl in UI noch offen.
|
||||||
- **Anker-Variante:** ✅ Client + DB (**C1**); Picker wählt Variante bei Treffer noch nicht (**C2**).
|
- **Anker-Variante:** ✅ Client + DB (**C1**); Picker wählt Variante bei Treffer (**C2** — Dropdown + Graph-Vorschlag).
|
||||||
- **Graph-Builder:** Ziel eingeben → aufbauende Übungen → in Graph speichern (**C3**) — Compound-Nutzen über viele Pläne.
|
- **Graph-Builder:** Ziel eingeben → aufbauende Übungen → in Graph speichern (**C3**) — Compound-Nutzen über viele Pläne.
|
||||||
- **Varianten-Suche:** Library-Picker nutzt `include_variants`; Planungs-KI rankt primär **Übungsebene** — Varianten-Expansion nur gezielt (**C2**).
|
- **Varianten-Suche:** Library-Picker nutzt `include_variants`; Planungs-KI rankt primär **Übungsebene** — Varianten-Expansion nur gezielt (**C2**).
|
||||||
- **Enrichment:** Superadmin-Tool für Skills; Datenqualität der Bibliothek entscheidend für Profil-Score.
|
- **Enrichment:** Superadmin-Tool für Skills; Datenqualität der Bibliothek entscheidend für Profil-Score.
|
||||||
|
|
@ -419,8 +419,14 @@ Treffer: optional `hits[].suggested_variant_id`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 20. Phase C2 / C3 — Roadmap (offen)
|
## 20. Phase C2 — Varianten in Treffern (0.8.184) ✅
|
||||||
|
|
||||||
**C2:** Varianten in Trefferliste / Picker-Auswahl bei Graph-Treffern.
|
- API: `variants[]`, `suggested_variant_name` pro Treffer (Batch aus `exercise_variants`).
|
||||||
|
- **`ExercisePickerModal`:** Dropdown pro Treffer; Graph-`suggested_variant_id` vorausgewählt; Übernahme setzt `exercise_variant_id`.
|
||||||
|
- **`hydrateExercisePlanningRow`:** übernimmt `exercise_variant_id` / `suggested_variant_id` in die Planungszeile.
|
||||||
|
|
||||||
**C3:** Graph-Builder — Ziel eingeben, aufbauende Übungen vorschlagen, nach Review in Graph speichern (`POST …/edges/sequence`). Nutzen über viele Pläne hinweg.
|
---
|
||||||
|
|
||||||
|
## 21. Phase C3 — Graph-Builder (Roadmap, offen)
|
||||||
|
|
||||||
|
Ziel eingeben → aufbauende Übungen vorschlagen → nach Review in Graph speichern (`POST …/edges/sequence`). Nutzen über viele Pläne hinweg.
|
||||||
|
|
|
||||||
|
|
@ -211,6 +211,79 @@ def _resolve_anchor_variant_id(
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _enrich_planning_hits_with_variant_meta(
|
||||||
|
cur,
|
||||||
|
hits: Sequence[Dict[str, Any]],
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Variantennamen und -listen für Treffer mit suggested_variant_id (Phase C2)."""
|
||||||
|
if not hits:
|
||||||
|
return []
|
||||||
|
variant_ids: Set[int] = set()
|
||||||
|
exercise_ids: Set[int] = set()
|
||||||
|
for h in hits:
|
||||||
|
exercise_ids.add(int(h["id"]))
|
||||||
|
raw = h.get("suggested_variant_id")
|
||||||
|
if raw is not None:
|
||||||
|
try:
|
||||||
|
vid = int(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
vid = 0
|
||||||
|
if vid > 0:
|
||||||
|
variant_ids.add(vid)
|
||||||
|
|
||||||
|
names_by_variant: Dict[int, str] = {}
|
||||||
|
if variant_ids:
|
||||||
|
ph = ",".join(["%s"] * len(variant_ids))
|
||||||
|
cur.execute(
|
||||||
|
f"SELECT id, variant_name FROM exercise_variants WHERE id IN ({ph})",
|
||||||
|
list(variant_ids),
|
||||||
|
)
|
||||||
|
for row in cur.fetchall():
|
||||||
|
n = (row.get("variant_name") or "").strip()
|
||||||
|
if n:
|
||||||
|
names_by_variant[int(row["id"])] = n
|
||||||
|
|
||||||
|
variants_by_exercise: Dict[int, List[Dict[str, Any]]] = {}
|
||||||
|
if exercise_ids:
|
||||||
|
ph = ",".join(["%s"] * len(exercise_ids))
|
||||||
|
cur.execute(
|
||||||
|
f"""
|
||||||
|
SELECT exercise_id, id, variant_name, sequence_order
|
||||||
|
FROM exercise_variants
|
||||||
|
WHERE exercise_id IN ({ph})
|
||||||
|
ORDER BY exercise_id, sequence_order NULLS LAST, id
|
||||||
|
""",
|
||||||
|
list(exercise_ids),
|
||||||
|
)
|
||||||
|
for row in cur.fetchall():
|
||||||
|
eid = int(row["exercise_id"])
|
||||||
|
variants_by_exercise.setdefault(eid, []).append(
|
||||||
|
{
|
||||||
|
"id": int(row["id"]),
|
||||||
|
"variant_name": (row.get("variant_name") or "").strip() or None,
|
||||||
|
"sequence_order": row.get("sequence_order"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
out: List[Dict[str, Any]] = []
|
||||||
|
for h in hits:
|
||||||
|
item = dict(h)
|
||||||
|
eid = int(item["id"])
|
||||||
|
vars_for_ex = variants_by_exercise.get(eid) or []
|
||||||
|
if vars_for_ex:
|
||||||
|
item["variants"] = vars_for_ex
|
||||||
|
raw_vid = item.get("suggested_variant_id")
|
||||||
|
if raw_vid is not None:
|
||||||
|
try:
|
||||||
|
vid = int(raw_vid)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
vid = 0
|
||||||
|
if vid > 0:
|
||||||
|
item["suggested_variant_name"] = names_by_variant.get(vid)
|
||||||
|
out.append(item)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _finalize_progression_context(
|
def _finalize_progression_context(
|
||||||
cur,
|
cur,
|
||||||
tenant: TenantContext,
|
tenant: TenantContext,
|
||||||
|
|
@ -728,6 +801,7 @@ def suggest_planning_exercises(
|
||||||
hits = hits[: int(body.limit)]
|
hits = hits[: int(body.limit)]
|
||||||
|
|
||||||
hits = hits[: int(body.limit)]
|
hits = hits[: int(body.limit)]
|
||||||
|
hits = _enrich_planning_hits_with_variant_meta(cur, hits)
|
||||||
|
|
||||||
context_summary = {
|
context_summary = {
|
||||||
"unit_title": pack.get("unit_title"),
|
"unit_title": pack.get("unit_title"),
|
||||||
|
|
|
||||||
43
backend/tests/test_planning_exercise_suggest_variants.py
Normal file
43
backend/tests/test_planning_exercise_suggest_variants.py
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
"""Tests Phase C2: Varianten-Metadaten in Planungs-Treffern."""
|
||||||
|
from planning_exercise_suggest import _enrich_planning_hits_with_variant_meta
|
||||||
|
|
||||||
|
|
||||||
|
class _Cur:
|
||||||
|
def __init__(self, rows):
|
||||||
|
self._rows = rows
|
||||||
|
self.last_query = ''
|
||||||
|
|
||||||
|
def execute(self, query, params=None):
|
||||||
|
self.last_query = query
|
||||||
|
self._params = params
|
||||||
|
|
||||||
|
def fetchall(self):
|
||||||
|
q = self.last_query
|
||||||
|
if 'FROM exercise_variants WHERE id IN' in q:
|
||||||
|
return [
|
||||||
|
{'id': 7, 'variant_name': 'Leicht'},
|
||||||
|
]
|
||||||
|
if 'FROM exercise_variants' in q and 'exercise_id IN' in q:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'exercise_id': 10,
|
||||||
|
'id': 7,
|
||||||
|
'variant_name': 'Leicht',
|
||||||
|
'sequence_order': 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'exercise_id': 10,
|
||||||
|
'id': 8,
|
||||||
|
'variant_name': 'Schwer',
|
||||||
|
'sequence_order': 2,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def test_enrich_planning_hits_with_variant_meta():
|
||||||
|
hits = [{'id': 10, 'title': 'Test', 'suggested_variant_id': 7}]
|
||||||
|
out = _enrich_planning_hits_with_variant_meta(_Cur([]), hits)
|
||||||
|
assert out[0]['suggested_variant_name'] == 'Leicht'
|
||||||
|
assert len(out[0]['variants']) == 2
|
||||||
|
assert out[0]['variants'][0]['id'] == 7
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.183"
|
APP_VERSION = "0.8.184"
|
||||||
BUILD_DATE = "2026-05-23"
|
BUILD_DATE = "2026-05-23"
|
||||||
DB_SCHEMA_VERSION = "20260531074"
|
DB_SCHEMA_VERSION = "20260531074"
|
||||||
|
|
||||||
|
|
@ -29,7 +29,7 @@ MODULE_VERSIONS = {
|
||||||
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
|
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
|
||||||
"methods": "0.1.0",
|
"methods": "0.1.0",
|
||||||
"exercises": "2.37.0", # Planungs-KI P1: Szenario-Pipeline + Query-Intent-Overlay
|
"exercises": "2.37.0", # Planungs-KI P1: Szenario-Pipeline + Query-Intent-Overlay
|
||||||
"planning_exercise_suggest": "0.11.0", # Phase C1: Graph auto-match + variantenbewusste Nachfolger
|
"planning_exercise_suggest": "0.12.0", # Phase C2: Varianten in Treffern + Übernahme
|
||||||
"training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint
|
"training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint
|
||||||
"training_programs": "0.1.0",
|
"training_programs": "0.1.0",
|
||||||
"planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung
|
"planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung
|
||||||
|
|
@ -44,6 +44,14 @@ MODULE_VERSIONS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
CHANGELOG = [
|
||||||
|
{
|
||||||
|
"version": "0.8.184",
|
||||||
|
"date": "2026-05-23",
|
||||||
|
"changes": [
|
||||||
|
"Planungs-KI Phase C2: Treffer mit Variantenliste + suggested_variant_name; Picker wählt Graph-Variante vor Übernahme.",
|
||||||
|
"hydrateExercisePlanningRow übernimmt exercise_variant_id / suggested_variant_id in die Planungszeile.",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.8.183",
|
"version": "0.8.183",
|
||||||
"date": "2026-05-23",
|
"date": "2026-05-23",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Shinkan Jinkendo – Entwicklungsstand & Handover
|
# Shinkan Jinkendo – Entwicklungsstand & Handover
|
||||||
|
|
||||||
**Stand:** 2026-05-23
|
**Stand:** 2026-05-23
|
||||||
**App-Version / DB-Schema:** App **`0.8.183`** (Planungs-KI Phase C1); DB **`20260531074`** — maßgeblich **`backend/version.py`**.
|
**App-Version / DB-Schema:** App **`0.8.184`** (Planungs-KI Phase C2); DB **`20260531074`** — maßgeblich **`backend/version.py`**.
|
||||||
|
|
||||||
Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand**, **Medien-Meilenstein** und **sinnvolle nächste Schritte**.
|
Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand**, **Medien-Meilenstein** und **sinnvolle nächste Schritte**.
|
||||||
|
|
||||||
|
|
@ -102,7 +102,7 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
|
||||||
| **A** | Voll-Library deterministisch ranken (kein OR-Profil-Pool) | ✅ **0.8.177** |
|
| **A** | Voll-Library deterministisch ranken (kein OR-Profil-Pool) | ✅ **0.8.177** |
|
||||||
| **B** | Text-Signale aus guidance/Rahmen-Zielen (`planning_text_signals`) | ✅ **0.8.181** |
|
| **B** | Text-Signale aus guidance/Rahmen-Zielen (`planning_text_signals`) | ✅ **0.8.181** |
|
||||||
| **C1** | Progressionsgraph auto-match + variantenbewusste Nachfolger | ✅ **0.8.183** |
|
| **C1** | Progressionsgraph auto-match + variantenbewusste Nachfolger | ✅ **0.8.183** |
|
||||||
| **C2** | Varianten in Trefferliste / Picker-Auswahl | 🔲 |
|
| **C2** | Varianten in Trefferliste / Picker-Auswahl | ✅ **0.8.184** |
|
||||||
| **C3** | Graph-Builder: Ziel → Pfad vorschlagen → in Graph speichern | 🔲 |
|
| **C3** | Graph-Builder: Ziel → Pfad vorschlagen → in Graph speichern | 🔲 |
|
||||||
| **D** | `planning_context` an `suggestExerciseAi` (Neu-Anlage) | 🔲 |
|
| **D** | `planning_context` an `suggestExerciseAi` (Neu-Anlage) | 🔲 |
|
||||||
|
|
||||||
|
|
@ -247,11 +247,10 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
|
||||||
|
|
||||||
### Planungs-KI (priorisiert)
|
### Planungs-KI (priorisiert)
|
||||||
|
|
||||||
1. **C2 — Varianten in Treffern:** Planungs-Picker: bei `suggested_variant_id` Variante vorauswählen; optional Varianten-Ranking bei `deepen`/`progression`.
|
1. **C3 — Graph-Builder:** Modus „Pfad zum Ziel“ → sequenzielle Vorschläge → `POST …/edges/sequence` nach Review.
|
||||||
2. **C3 — Graph-Builder:** Modus „Pfad zum Ziel“ → sequenzielle Vorschläge → `POST …/edges/sequence` nach Review.
|
2. **Graph-Auswahl UI:** Dropdown neben Auto-Match; Rahmen-Slot mit Default-Graph verknüpfen.
|
||||||
3. **Graph-Auswahl UI:** Dropdown neben Auto-Match; Rahmen-Slot / Framework mit Default-Graph verknüpfen.
|
3. **Enrichment:** Skills für Kern-Übungen nachziehen (sonst schwaches Profil-Ranking).
|
||||||
4. **Enrichment:** Skills für Kern-Übungen nachziehen (sonst schwaches Profil-Ranking).
|
4. **D — Neu-Anlage:** `planning_context_json` an `POST /api/exercises/ai/suggest`.
|
||||||
5. **D — Neu-Anlage:** `planning_context_json` an `POST /api/exercises/ai/suggest`.
|
|
||||||
|
|
||||||
### Allgemein
|
### Allgemein
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import {
|
||||||
buildQuickCreateExercisePayloadFromDraft,
|
buildQuickCreateExercisePayloadFromDraft,
|
||||||
aiPreviewToQuickCreateDraft,
|
aiPreviewToQuickCreateDraft,
|
||||||
} from '../utils/exerciseAiQuickCreate'
|
} from '../utils/exerciseAiQuickCreate'
|
||||||
|
import { resolveExercisePickVariantId } from '../utils/exercisePlanningPick'
|
||||||
|
|
||||||
const PAGE_SIZE = 100
|
const PAGE_SIZE = 100
|
||||||
/** Backend POST /api/planning/exercise-suggest erlaubt max. 50 */
|
/** Backend POST /api/planning/exercise-suggest erlaubt max. 50 */
|
||||||
|
|
@ -89,6 +90,7 @@ export default function ExercisePickerModal({
|
||||||
const [planningHasSearched, setPlanningHasSearched] = useState(false)
|
const [planningHasSearched, setPlanningHasSearched] = useState(false)
|
||||||
const [planningSubmittedQuery, setPlanningSubmittedQuery] = useState('')
|
const [planningSubmittedQuery, setPlanningSubmittedQuery] = useState('')
|
||||||
const [planningSearchTick, setPlanningSearchTick] = useState(0)
|
const [planningSearchTick, setPlanningSearchTick] = useState(0)
|
||||||
|
const [variantPickByExerciseId, setVariantPickByExerciseId] = useState({})
|
||||||
const pickerScrollRef = useRef(null)
|
const pickerScrollRef = useRef(null)
|
||||||
|
|
||||||
const resolvedPlanningUnitId = useMemo(() => {
|
const resolvedPlanningUnitId = useMemo(() => {
|
||||||
|
|
@ -171,8 +173,38 @@ export default function ExercisePickerModal({
|
||||||
} = useExerciseAiQuickCreateFields(effectivePickerQuery, { enabled: open && enableQuickCreateDraft })
|
} = useExerciseAiQuickCreateFields(effectivePickerQuery, { enabled: open && enableQuickCreateDraft })
|
||||||
|
|
||||||
const toggleMultiPick = (ex) => {
|
const toggleMultiPick = (ex) => {
|
||||||
|
setMultiPicked((prev) => {
|
||||||
|
if (prev.some((p) => p.id === ex.id)) return prev.filter((p) => p.id !== ex.id)
|
||||||
|
const vid = resolveExercisePickVariantId(ex, variantPickByExerciseId[ex.id])
|
||||||
|
return [...prev, { ...ex, exercise_variant_id: vid }]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildExercisePickPayload = (ex) => {
|
||||||
|
const vid = resolveExercisePickVariantId(ex, variantPickByExerciseId[ex.id])
|
||||||
|
return {
|
||||||
|
...ex,
|
||||||
|
exercise_variant_id: vid,
|
||||||
|
suggested_variant_id: vid ?? ex.suggested_variant_id ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const setVariantPickForExercise = (exerciseId, variantId) => {
|
||||||
|
const eid = Number(exerciseId)
|
||||||
|
if (!Number.isFinite(eid) || eid < 1) return
|
||||||
|
setVariantPickByExerciseId((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[eid]: variantId === '' || variantId == null ? null : Number(variantId),
|
||||||
|
}))
|
||||||
setMultiPicked((prev) =>
|
setMultiPicked((prev) =>
|
||||||
prev.some((p) => p.id === ex.id) ? prev.filter((p) => p.id !== ex.id) : [...prev, ex]
|
prev.map((p) =>
|
||||||
|
Number(p.id) === eid
|
||||||
|
? {
|
||||||
|
...p,
|
||||||
|
exercise_variant_id: resolveExercisePickVariantId(p, variantId === '' ? null : Number(variantId)),
|
||||||
|
}
|
||||||
|
: p,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -230,6 +262,54 @@ export default function ExercisePickerModal({
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const renderPlanningVariantPick = (ex) => {
|
||||||
|
if (!usePlanningSearch || !ex?.id) return null
|
||||||
|
const variants = Array.isArray(ex.variants) ? ex.variants : []
|
||||||
|
const resolved = resolveExercisePickVariantId(ex, variantPickByExerciseId[ex.id])
|
||||||
|
if (ex.suggested_variant_name && !variants.length) {
|
||||||
|
return (
|
||||||
|
<span className="exercise-tag" style={{ marginTop: 6, display: 'inline-block' }}>
|
||||||
|
Variante: {ex.suggested_variant_name}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (variants.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{ marginTop: 8, display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<label className="form-label" style={{ margin: 0, fontSize: '11px' }}>
|
||||||
|
Variante
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="form-input"
|
||||||
|
style={{ fontSize: '12px', padding: '4px 8px', maxWidth: '100%', flex: '1 1 160px' }}
|
||||||
|
value={resolved ?? ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value
|
||||||
|
setVariantPickForExercise(ex.id, v === '' ? null : Number(v))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">— Standard —</option>
|
||||||
|
{variants.map((v) => (
|
||||||
|
<option key={v.id} value={v.id}>
|
||||||
|
{v.variant_name || `Variante #${v.id}`}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{ex.suggested_variant_id && Number(ex.suggested_variant_id) === Number(resolved) ? (
|
||||||
|
<span style={{ fontSize: '11px', color: 'var(--accent-dark)' }}>Progressionsgraph</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) setVariantPickByExerciseId({})
|
||||||
|
}, [open])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
@ -505,11 +585,18 @@ export default function ExercisePickerModal({
|
||||||
title: h.title,
|
title: h.title,
|
||||||
summary: h.summary,
|
summary: h.summary,
|
||||||
focus_area: h.focus_area,
|
focus_area: h.focus_area,
|
||||||
|
variants: Array.isArray(h.variants) ? h.variants : [],
|
||||||
suggested_variant_id: h.suggested_variant_id ?? null,
|
suggested_variant_id: h.suggested_variant_id ?? null,
|
||||||
|
suggested_variant_name: h.suggested_variant_name ?? null,
|
||||||
_planningScore: h.score,
|
_planningScore: h.score,
|
||||||
_planningReasons: Array.isArray(h.reasons) ? h.reasons : [],
|
_planningReasons: Array.isArray(h.reasons) ? h.reasons : [],
|
||||||
updated_at: new Date().toISOString(),
|
updated_at: new Date().toISOString(),
|
||||||
}))
|
}))
|
||||||
|
const initialVariants = {}
|
||||||
|
for (const h of hits) {
|
||||||
|
if (h.suggested_variant_id) initialVariants[h.id] = Number(h.suggested_variant_id)
|
||||||
|
}
|
||||||
|
setVariantPickByExerciseId(initialVariants)
|
||||||
setList(hits)
|
setList(hits)
|
||||||
setHasMore(false)
|
setHasMore(false)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -581,7 +668,8 @@ export default function ExercisePickerModal({
|
||||||
estimateSize: (index) => {
|
estimateSize: (index) => {
|
||||||
const ex = list[index]
|
const ex = list[index]
|
||||||
const rc = ex?._planningReasons?.length || 0
|
const rc = ex?._planningReasons?.length || 0
|
||||||
return rc > 0 ? 96 + Math.min(rc, 3) * 14 : 88
|
const hasVariant = usePlanningSearch && Array.isArray(ex?.variants) && ex.variants.length > 0
|
||||||
|
return rc > 0 || hasVariant ? 96 + Math.min(rc, 3) * 14 + (hasVariant ? 36 : 0) : 88
|
||||||
},
|
},
|
||||||
overscan: 8,
|
overscan: 8,
|
||||||
getItemKey: (index) => String(list[index]?.id ?? index),
|
getItemKey: (index) => String(list[index]?.id ?? index),
|
||||||
|
|
@ -591,10 +679,11 @@ export default function ExercisePickerModal({
|
||||||
|
|
||||||
const adoptExistingExercise = async (ex) => {
|
const adoptExistingExercise = async (ex) => {
|
||||||
if (!ex?.id) return
|
if (!ex?.id) return
|
||||||
|
const payload = buildExercisePickPayload(ex)
|
||||||
if (multiSelect && typeof onSelectExercises === 'function') {
|
if (multiSelect && typeof onSelectExercises === 'function') {
|
||||||
await Promise.resolve(onSelectExercises([ex]))
|
await Promise.resolve(onSelectExercises([payload]))
|
||||||
} else if (typeof onSelectExercise === 'function') {
|
} else if (typeof onSelectExercise === 'function') {
|
||||||
await Promise.resolve(onSelectExercise(ex))
|
await Promise.resolve(onSelectExercise(payload))
|
||||||
}
|
}
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
|
|
@ -1186,6 +1275,7 @@ export default function ExercisePickerModal({
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
) : null}
|
) : null}
|
||||||
|
{renderPlanningVariantPick(ex)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
|
|
@ -1232,10 +1322,7 @@ export default function ExercisePickerModal({
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => adoptExistingExercise(ex)}
|
||||||
onSelectExercise(ex)
|
|
||||||
onClose()
|
|
||||||
}}
|
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
textAlign: 'left',
|
textAlign: 'left',
|
||||||
|
|
@ -1303,7 +1390,7 @@ export default function ExercisePickerModal({
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
disabled={!multiPicked.length}
|
disabled={!multiPicked.length}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onSelectExercises([...multiPicked])
|
onSelectExercises(multiPicked.map((p) => buildExercisePickPayload(p)))
|
||||||
onClose()
|
onClose()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -500,13 +500,17 @@ export default function ExerciseProgressionGraphPanel({
|
||||||
|
|
||||||
const applyPickedExercise = async (ex) => {
|
const applyPickedExercise = async (ex) => {
|
||||||
const title = ex.title || `Übung #${ex.id}`
|
const title = ex.title || `Übung #${ex.id}`
|
||||||
const variants = await loadVariantsForExercise(ex.id)
|
const variants = Array.isArray(ex.variants) && ex.variants.length
|
||||||
|
? ex.variants
|
||||||
|
: await loadVariantsForExercise(ex.id)
|
||||||
|
const variantId =
|
||||||
|
ex.exercise_variant_id ?? ex.suggested_variant_id ?? null
|
||||||
|
|
||||||
if (pickContext?.kind === 'sequence') {
|
if (pickContext?.kind === 'sequence') {
|
||||||
patchSeqStep(pickContext.index, {
|
patchSeqStep(pickContext.index, {
|
||||||
exerciseId: ex.id,
|
exerciseId: ex.id,
|
||||||
exerciseTitle: title,
|
exerciseTitle: title,
|
||||||
variantId: null,
|
variantId: variantId != null ? Number(variantId) : null,
|
||||||
variants,
|
variants,
|
||||||
})
|
})
|
||||||
setPickContext(null)
|
setPickContext(null)
|
||||||
|
|
@ -516,7 +520,7 @@ export default function ExerciseProgressionGraphPanel({
|
||||||
const patch = {
|
const patch = {
|
||||||
exerciseId: ex.id,
|
exerciseId: ex.id,
|
||||||
exerciseTitle: title,
|
exerciseTitle: title,
|
||||||
variantId: null,
|
variantId: variantId != null ? Number(variantId) : null,
|
||||||
variants,
|
variants,
|
||||||
}
|
}
|
||||||
if (pickContext.slot === 'first') setFirstEp(patch)
|
if (pickContext.slot === 'first') setFirstEp(patch)
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,12 @@ export function mapPlanningHitsToListRows(hits) {
|
||||||
summary: h.summary,
|
summary: h.summary,
|
||||||
focus_area: h.focus_area,
|
focus_area: h.focus_area,
|
||||||
focus_area_names: h.focus_area ? [h.focus_area] : [],
|
focus_area_names: h.focus_area ? [h.focus_area] : [],
|
||||||
|
variants: Array.isArray(h.variants) ? h.variants : [],
|
||||||
|
suggested_variant_id: h.suggested_variant_id ?? null,
|
||||||
|
suggested_variant_name: h.suggested_variant_name ?? null,
|
||||||
updated_at: new Date().toISOString(),
|
updated_at: new Date().toISOString(),
|
||||||
_planningScore: h.score,
|
_planningScore: h.score,
|
||||||
_planningReasons: Array.isArray(h.reasons) ? h.reasons : [],
|
_planningReasons: Array.isArray(h.reasons) ? h.reasons : [],
|
||||||
suggested_variant_id: h.suggested_variant_id ?? null,
|
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
18
frontend/src/utils/exercisePlanningPick.js
Normal file
18
frontend/src/utils/exercisePlanningPick.js
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
/** Varianten-Auswahl beim Übernehmen aus Planungs-KI-Treffern. */
|
||||||
|
export function resolveExercisePickVariantId(exercise, overrideVariantId) {
|
||||||
|
if (exercise == null) return null
|
||||||
|
const raw =
|
||||||
|
overrideVariantId !== undefined && overrideVariantId !== null && overrideVariantId !== ''
|
||||||
|
? overrideVariantId
|
||||||
|
: exercise.exercise_variant_id ?? exercise.suggested_variant_id
|
||||||
|
if (raw == null || raw === '') return null
|
||||||
|
const vid = Number(raw)
|
||||||
|
return Number.isFinite(vid) && vid > 0 ? vid : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function variantLabelForPick(variants, variantId) {
|
||||||
|
if (variantId == null) return null
|
||||||
|
const list = Array.isArray(variants) ? variants : []
|
||||||
|
const row = list.find((v) => Number(v.id) === Number(variantId))
|
||||||
|
return (row?.variant_name || '').trim() || null
|
||||||
|
}
|
||||||
|
|
@ -325,6 +325,18 @@ export async function hydrateExercisePlanningRow(exercise) {
|
||||||
row.exercise_variant_id = ''
|
row.exercise_variant_id = ''
|
||||||
} else {
|
} else {
|
||||||
row.variants = variants
|
row.variants = variants
|
||||||
|
const pickVidRaw =
|
||||||
|
exercise?.exercise_variant_id ?? exercise?.suggested_variant_id ?? null
|
||||||
|
const pickVid =
|
||||||
|
pickVidRaw != null && pickVidRaw !== '' ? Number(pickVidRaw) : NaN
|
||||||
|
if (Number.isFinite(pickVid) && pickVid > 0) {
|
||||||
|
const known = variants.some((v) => Number(v.id) === pickVid)
|
||||||
|
if (known || !variants.length) row.exercise_variant_id = pickVid
|
||||||
|
}
|
||||||
|
if (row.exercise_variant_id !== '' && variants.length > 0) {
|
||||||
|
const ok = variants.some((v) => Number(v.id) === Number(row.exercise_variant_id))
|
||||||
|
if (!ok) row.exercise_variant_id = ''
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Object.assign(row, meta)
|
Object.assign(row, meta)
|
||||||
if (row.exercise_kind === 'combination') {
|
if (row.exercise_kind === 'combination') {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user