Enhance Planning Exercise Suggestion with Client Context and Group ID Support
All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 40s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m16s

- Made `unit_id` and `group_id` optional in `PlanningExerciseSuggestRequest` to support client context without a saved unit.
- Refactored `_load_group_recent_exercise_ids` to handle cases where `exclude_unit_id` is optional.
- Introduced `build_client_planning_context_pack` for improved context handling in client-free searches.
- Updated `suggest_planning_exercises` to utilize the new client context pack when `unit_id` is not provided.
- Incremented version to 0.8.172 and updated changelog to reflect these enhancements in the planning AI capabilities.
This commit is contained in:
Lars 2026-05-22 22:38:21 +02:00
parent f5c886fc13
commit 614c2dcfaa
7 changed files with 330 additions and 67 deletions

View File

@ -48,7 +48,8 @@ _LLM_RERANK_PRE_LIMIT = 32
class PlanningExerciseSuggestRequest(BaseModel): class PlanningExerciseSuggestRequest(BaseModel):
unit_id: int = Field(..., ge=1) unit_id: Optional[int] = Field(default=None, ge=1)
group_id: Optional[int] = Field(default=None, ge=1)
section_order_index: Optional[int] = Field(default=None, ge=0) section_order_index: Optional[int] = Field(default=None, ge=0)
phase_order_index: Optional[int] = Field(default=None, ge=0) phase_order_index: Optional[int] = Field(default=None, ge=0)
parallel_stream_order_index: Optional[int] = Field(default=None, ge=0) parallel_stream_order_index: Optional[int] = Field(default=None, ge=0)
@ -196,26 +197,42 @@ def _load_progression_successors(
def _load_group_recent_exercise_ids( def _load_group_recent_exercise_ids(
cur, cur,
group_id: Optional[int], group_id: Optional[int],
exclude_unit_id: int, exclude_unit_id: Optional[int] = None,
limit: int = 40, limit: int = 40,
) -> Set[int]: ) -> Set[int]:
if not group_id: if not group_id:
return set() return set()
cur.execute( if exclude_unit_id is not None:
""" cur.execute(
SELECT tusi.exercise_id AS eid """
FROM training_units tu SELECT tusi.exercise_id AS eid
INNER JOIN training_unit_sections tus ON tus.training_unit_id = tu.id FROM training_units tu
INNER JOIN training_unit_section_items tusi ON tusi.section_id = tus.id INNER JOIN training_unit_sections tus ON tus.training_unit_id = tu.id
WHERE tu.group_id = %s INNER JOIN training_unit_section_items tusi ON tusi.section_id = tus.id
AND tu.id <> %s WHERE tu.group_id = %s
AND tusi.exercise_id IS NOT NULL AND tu.id <> %s
AND COALESCE(tu.status, '') <> 'cancelled' AND tusi.exercise_id IS NOT NULL
ORDER BY tu.planned_date DESC NULLS LAST, tu.id DESC, tusi.order_index DESC AND COALESCE(tu.status, '') <> 'cancelled'
LIMIT 200 ORDER BY tu.planned_date DESC NULLS LAST, tu.id DESC, tusi.order_index DESC
""", LIMIT 200
(int(group_id), int(exclude_unit_id)), """,
) (int(group_id), int(exclude_unit_id)),
)
else:
cur.execute(
"""
SELECT tusi.exercise_id AS eid
FROM training_units tu
INNER JOIN training_unit_sections tus ON tus.training_unit_id = tu.id
INNER JOIN training_unit_section_items tusi ON tusi.section_id = tus.id
WHERE tu.group_id = %s
AND tusi.exercise_id IS NOT NULL
AND COALESCE(tu.status, '') <> 'cancelled'
ORDER BY tu.planned_date DESC NULLS LAST, tu.id DESC, tusi.order_index DESC
LIMIT 200
""",
(int(group_id),),
)
out: Set[int] = set() out: Set[int] = set()
for r in cur.fetchall(): for r in cur.fetchall():
if r.get("eid") is None: if r.get("eid") is None:
@ -364,13 +381,82 @@ def build_planning_exercise_context_pack(
} }
def build_client_planning_context_pack(
cur,
*,
tenant: TenantContext,
body: PlanningExerciseSuggestRequest,
) -> Dict[str, Any]:
"""Freie / Client-Kontext-Suche ohne persistierte training_units.id (Formular, Rahmen-Slot)."""
role = tenant.global_role
if not _has_planning_role(role):
raise HTTPException(status_code=403, detail="Nur Trainer dürfen Planungs-Vorschläge abrufen")
planned_ids: List[int] = []
if body.planned_exercise_ids:
seen: Set[int] = set()
for raw in body.planned_exercise_ids:
try:
eid = int(raw)
except (TypeError, ValueError):
continue
if eid < 1 or eid in seen:
continue
seen.add(eid)
planned_ids.append(eid)
anchor_id = _resolve_anchor_from_plan(planned_ids, body.anchor_exercise_id)
anchor_skills = _load_skill_ids_for_exercise(cur, anchor_id)
progression_ids, progression_notes = _load_progression_successors(
cur, body.progression_graph_id, anchor_id
)
group_id = body.group_id
group_name = None
if group_id:
cur.execute("SELECT name FROM training_groups WHERE id = %s", (int(group_id),))
gr = cur.fetchone()
if gr:
group_name = (gr.get("name") or "").strip() or None
group_recent = _load_group_recent_exercise_ids(cur, group_id, exclude_unit_id=None)
titles = _load_exercise_titles(cur, [x for x in [anchor_id] if x])
anchor_title = titles.get(anchor_id) if anchor_id else None
return {
"unit_id": None,
"unit": {
"id": None,
"framework_slot_id": None,
"origin_framework_slot_id": None,
},
"unit_title": None,
"group_id": group_id,
"group_name": group_name,
"section_order_index": body.section_order_index,
"section_title": None,
"planned_exercise_ids": planned_ids,
"anchor_exercise_id": anchor_id,
"anchor_title": anchor_title,
"anchor_skill_ids": sorted(anchor_skills),
"progression_graph_id": body.progression_graph_id,
"progression_successor_ids": sorted(progression_ids),
"progression_edge_notes": progression_notes,
"group_recent_exercise_ids": sorted(group_recent),
"context_mode": "client_free",
}
def suggest_planning_exercises( def suggest_planning_exercises(
cur, cur,
*, *,
tenant: TenantContext, tenant: TenantContext,
body: PlanningExerciseSuggestRequest, body: PlanningExerciseSuggestRequest,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
pack = build_planning_exercise_context_pack(cur, tenant=tenant, body=body) if body.unit_id:
pack = build_planning_exercise_context_pack(cur, tenant=tenant, body=body)
else:
pack = build_client_planning_context_pack(cur, tenant=tenant, body=body)
pack = _apply_client_planned_override(cur, pack, body) pack = _apply_client_planned_override(cur, pack, body)
query = _normalize_query(body.query) query = _normalize_query(body.query)
heuristic_intent = resolve_planning_exercise_intent(query, body.intent_hint) heuristic_intent = resolve_planning_exercise_intent(query, body.intent_hint)
@ -601,6 +687,7 @@ def suggest_planning_exercises(
"anchor_title": pack.get("anchor_title"), "anchor_title": pack.get("anchor_title"),
"anchor_exercise_id": pack.get("anchor_exercise_id"), "anchor_exercise_id": pack.get("anchor_exercise_id"),
"progression_graph_id": pack.get("progression_graph_id"), "progression_graph_id": pack.get("progression_graph_id"),
"context_mode": pack.get("context_mode") or ("unit" if pack.get("unit_id") else "client_free"),
} }
return { return {

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.171" APP_VERSION = "0.8.172"
BUILD_DATE = "2026-05-22" BUILD_DATE = "2026-05-22"
DB_SCHEMA_VERSION = "20260531073" DB_SCHEMA_VERSION = "20260531073"
@ -28,7 +28,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.4.0", # include_llm_intent, scenario_kind, query_intent_summary "planning_exercise_suggest": "0.4.1", # unit_id optional; client_free Kontext; group_id
"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
@ -43,6 +43,14 @@ MODULE_VERSIONS = {
} }
CHANGELOG = [ CHANGELOG = [
{
"version": "0.8.172",
"date": "2026-05-22",
"changes": [
"Planungs-KI UI: Menüpunkt „Planungs-KI: Übung vorschlagen“ im +-Dialog; Freitext-Suche ohne gespeicherte unit_id (client_free).",
"API exercise-suggest: unit_id optional, group_id für Client-Kontext.",
],
},
{ {
"version": "0.8.171", "version": "0.8.171",
"date": "2026-05-22", "date": "2026-05-22",

View File

@ -40,7 +40,11 @@ export default function ExercisePickerModal({
enableQuickCreateDraft = false, enableQuickCreateDraft = false,
/** Gespeicherte training_units.id — aktiviert Planungs-KI (robuster als nur planningContext). */ /** Gespeicherte training_units.id — aktiviert Planungs-KI (robuster als nur planningContext). */
planningUnitId = null, planningUnitId = null,
/** true auf TrainingUnitEditPage: Hinweis wenn Einheit noch keine ID hat. */ /** 'planning' = Planungs-KI-API; 'library' = Volltext-Bibliothek */
pickerMode = 'library',
/** Planungs-KI auch ohne gespeicherte unit_id (Client-Kontext / Freitext). */
enableFreePlanningSearch = false,
/** true auf TrainingUnitEditPage: Hinweis wenn Planungs-KI ohne Einheit und ohne Freitext. */
expectPlanningSearch = false, expectPlanningSearch = false,
/** Planungs-Kontext für KI-Suche (Abschnitt, Anker, Plan …) */ /** Planungs-Kontext für KI-Suche (Abschnitt, Anker, Plan …) */
planningContext = null, planningContext = null,
@ -84,9 +88,11 @@ export default function ExercisePickerModal({
}, [planningUnitId, planningContext?.unitId]) }, [planningUnitId, planningContext?.unitId])
const activePlanningContext = useMemo(() => { const activePlanningContext = useMemo(() => {
if (!resolvedPlanningUnitId) return null if (pickerMode !== 'planning') return null
return { const groupIdRaw = planningContext?.groupId
unitId: resolvedPlanningUnitId, const groupId = Number(groupIdRaw)
const base = {
groupId: Number.isFinite(groupId) && groupId > 0 ? groupId : null,
sectionOrderIndex: planningContext?.sectionOrderIndex ?? 0, sectionOrderIndex: planningContext?.sectionOrderIndex ?? 0,
phaseOrderIndex: planningContext?.phaseOrderIndex ?? null, phaseOrderIndex: planningContext?.phaseOrderIndex ?? null,
parallelStreamOrderIndex: planningContext?.parallelStreamOrderIndex ?? null, parallelStreamOrderIndex: planningContext?.parallelStreamOrderIndex ?? null,
@ -97,10 +103,24 @@ export default function ExercisePickerModal({
: [], : [],
intentHint: planningContext?.intentHint ?? null, intentHint: planningContext?.intentHint ?? null,
} }
}, [resolvedPlanningUnitId, planningContext]) if (!resolvedPlanningUnitId) {
if (!enableFreePlanningSearch && !planningContext) return null
return { unitId: null, ...base }
}
return {
unitId: resolvedPlanningUnitId,
...base,
}
}, [pickerMode, resolvedPlanningUnitId, enableFreePlanningSearch, planningContext])
const usePlanningSearch = resolvedPlanningUnitId != null const usePlanningSearch = pickerMode === 'planning' && activePlanningContext != null
const planningSearchBlocked = Boolean(expectPlanningSearch && !usePlanningSearch) const useFreePlanningSearch = usePlanningSearch && !resolvedPlanningUnitId
const planningSearchBlocked = Boolean(
pickerMode === 'planning' &&
expectPlanningSearch &&
!resolvedPlanningUnitId &&
!enableFreePlanningSearch
)
/** Gemeinsamer Suchtext — in Planung nur ein Feld; in Bibliothek beide Felder kombiniert. */ /** Gemeinsamer Suchtext — in Planung nur ein Feld; in Bibliothek beide Felder kombiniert. */
const effectivePickerQuery = useMemo(() => { const effectivePickerQuery = useMemo(() => {
@ -306,8 +326,7 @@ export default function ExercisePickerModal({
try { try {
if (usePlanningSearch && activePlanningContext) { if (usePlanningSearch && activePlanningContext) {
const query = effectivePickerQuery const query = effectivePickerQuery
const res = await api.suggestPlanningExercises({ const requestBody = {
unit_id: Number(activePlanningContext.unitId),
section_order_index: section_order_index:
activePlanningContext.sectionOrderIndex != null activePlanningContext.sectionOrderIndex != null
? Number(activePlanningContext.sectionOrderIndex) ? Number(activePlanningContext.sectionOrderIndex)
@ -336,13 +355,20 @@ export default function ExercisePickerModal({
.filter((x) => Number.isFinite(x) && x > 0) .filter((x) => Number.isFinite(x) && x > 0)
: undefined, : undefined,
include_llm_intent: Boolean(query), include_llm_intent: Boolean(query),
include_llm_rank: true, include_llm_rank: Boolean(query),
query, query,
intent_hint: activePlanningContext.intentHint || null, intent_hint: activePlanningContext.intentHint || (useFreePlanningSearch && query ? 'free_search' : null),
limit: PAGE_SIZE, limit: PAGE_SIZE,
exercise_kind_any: exercise_kind_any:
Array.isArray(exerciseKindAny) && exerciseKindAny.length > 0 ? exerciseKindAny : undefined, Array.isArray(exerciseKindAny) && exerciseKindAny.length > 0 ? exerciseKindAny : undefined,
}) }
if (resolvedPlanningUnitId) {
requestBody.unit_id = Number(resolvedPlanningUnitId)
}
if (activePlanningContext.groupId) {
requestBody.group_id = Number(activePlanningContext.groupId)
}
const res = await api.suggestPlanningExercises(requestBody)
setPlanningContextSummary(res?.context_summary || null) setPlanningContextSummary(res?.context_summary || null)
setPlanningTargetProfileSummary(res?.target_profile_summary || null) setPlanningTargetProfileSummary(res?.target_profile_summary || null)
setPlanningLlmRankApplied(Boolean(res?.llm_rank_applied)) setPlanningLlmRankApplied(Boolean(res?.llm_rank_applied))
@ -397,6 +423,8 @@ export default function ExercisePickerModal({
activePlanningContext, activePlanningContext,
effectivePickerQuery, effectivePickerQuery,
exerciseKindAny, exerciseKindAny,
resolvedPlanningUnitId,
useFreePlanningSearch,
]) ])
useEffect(() => { useEffect(() => {
@ -643,12 +671,11 @@ export default function ExercisePickerModal({
lineHeight: 1.45, lineHeight: 1.45,
}} }}
> >
<strong>Planungs-KI noch nicht verfügbar.</strong> Die Einheit hat noch keine gespeicherte ID <strong>Planungs-KI noch nicht verfügbar.</strong> Bitte zuerst <strong>Speichern</strong> oder den
bitte zuerst <strong>Speichern</strong>, dann den Übungspicker erneut öffnen. Bis dahin gilt nur die Menüpunkt <strong>Planungs-KI: Übung vorschlagen</strong> nutzen (Freitext ohne gespeicherte Einheit).
Bibliothekssuche (Volltext).
</p> </p>
) : null} ) : null}
{!usePlanningSearch && !planningSearchBlocked ? ( {pickerMode === 'library' && expectPlanningSearch ? (
<p <p
style={{ style={{
margin: '0 0 10px', margin: '0 0 10px',
@ -660,9 +687,40 @@ export default function ExercisePickerModal({
color: 'var(--text2)', color: 'var(--text2)',
}} }}
> >
<strong style={{ color: 'var(--text1)' }}>Bibliothekssuche (Volltext)</strong> Planungs-KI mit <strong style={{ color: 'var(--text1)' }}>Bibliothekssuche (Volltext)</strong> für Planungs-KI mit
Kontext (Einheit, Plan, Anker) gibt es in der{' '} Kontext oder Freitext-Anfrage den Menüpunkt{' '}
<strong>Trainingseinheit bearbeiten</strong>, nach dem Speichern der Einheit. <strong>Planungs-KI: Übung vorschlagen </strong> unter dem <strong>+</strong> wählen.
</p>
) : null}
{useFreePlanningSearch ? (
<p
style={{
margin: '0 0 10px',
padding: '8px 10px',
borderRadius: '8px',
background: 'var(--surface2)',
border: '1px solid var(--border)',
fontSize: '12px',
color: 'var(--text2)',
}}
>
<strong style={{ color: 'var(--text1)' }}>Freie Planungs-KI</strong> Anker und bisherige Übungen aus
dem Formular; nach Speichern kommen Gruppe, Historie und Rahmen dazu.
</p>
) : null}
{!usePlanningSearch && !planningSearchBlocked && !expectPlanningSearch ? (
<p
style={{
margin: '0 0 10px',
padding: '8px 10px',
borderRadius: '8px',
background: 'var(--surface2)',
border: '1px solid var(--border)',
fontSize: '12px',
color: 'var(--text2)',
}}
>
<strong style={{ color: 'var(--text1)' }}>Bibliothekssuche (Volltext)</strong>
</p> </p>
) : null} ) : null}
<div style={{ display: 'grid', gap: '0.65rem' }}> <div style={{ display: 'grid', gap: '0.65rem' }}>

View File

@ -253,6 +253,7 @@ export default function TrainingUnitSectionsEditor({
sections, sections,
onSectionsChange, onSectionsChange,
onRequestExercisePick, onRequestExercisePick,
onRequestPlanningExercisePick,
onRequestTrainingModulePick, onRequestTrainingModulePick,
onPeekExercise, onPeekExercise,
showExecutionExtras = false, showExecutionExtras = false,
@ -2591,12 +2592,23 @@ export default function TrainingUnitSectionsEditor({
</p> </p>
) : ( ) : (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
{onRequestPlanningExercisePick ? (
<button
type="button"
className="btn btn-primary framework-ctrl framework-ctrl--xs"
onClick={() =>
onRequestPlanningExercisePick?.({ sectionIndex: sIdx })
}
>
+ Planungs-KI
</button>
) : null}
<button <button
type="button" type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs" className="btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick={() => onRequestExercisePick?.({ sectionIndex: sIdx })} onClick={() => onRequestExercisePick?.({ sectionIndex: sIdx })}
> >
+ Übung + Übung (Bibliothek)
</button> </button>
<button <button
type="button" type="button"
@ -2807,9 +2819,25 @@ export default function TrainingUnitSectionsEditor({
ändern. ändern.
</p> </p>
<div className="tu-insert-chooser-actions"> <div className="tu-insert-chooser-actions">
{onRequestPlanningExercisePick ? (
<button
type="button"
className="btn btn-primary tu-insert-chooser-actions__full"
onClick={() => {
const { sIdx, beforeIx } = insertChooser
closeInsertChooser()
onRequestPlanningExercisePick?.({
sectionIndex: sIdx,
insertBeforeIndex: beforeIx,
})
}}
>
Planungs-KI: Übung vorschlagen
</button>
) : null}
<button <button
type="button" type="button"
className="btn btn-primary tu-insert-chooser-actions__full" className={`btn ${onRequestPlanningExercisePick ? 'btn-secondary' : 'btn-primary'} tu-insert-chooser-actions__full`}
onClick={() => { onClick={() => {
const { sIdx, beforeIx } = insertChooser const { sIdx, beforeIx } = insertChooser
closeInsertChooser() closeInsertChooser()
@ -2819,7 +2847,7 @@ export default function TrainingUnitSectionsEditor({
}) })
}} }}
> >
Übung auswählen {onRequestPlanningExercisePick ? 'Übung aus Bibliothek …' : 'Übung auswählen …'}
</button> </button>
{onRequestTrainingModulePick ? ( {onRequestTrainingModulePick ? (
<button <button

View File

@ -37,6 +37,7 @@ export default function TrainingUnitFormShell({
onRequestSaveAsModule, onRequestSaveAsModule,
onRequestTrainingModulePick, onRequestTrainingModulePick,
onRequestExercisePick, onRequestExercisePick,
onRequestPlanningExercisePick,
onPeekExercise, onPeekExercise,
formId = 'planning-unit-form', formId = 'planning-unit-form',
}) { }) {
@ -427,6 +428,7 @@ export default function TrainingUnitFormShell({
} }
onRequestTrainingModulePick={onRequestTrainingModulePick} onRequestTrainingModulePick={onRequestTrainingModulePick}
onRequestExercisePick={onRequestExercisePick} onRequestExercisePick={onRequestExercisePick}
onRequestPlanningExercisePick={onRequestPlanningExercisePick}
onPeekExercise={onPeekExercise} onPeekExercise={onPeekExercise}
showExecutionExtras={Boolean(editingUnit) && sectionsEditMode === 'debrief'} showExecutionExtras={Boolean(editingUnit) && sectionsEditMode === 'debrief'}
enableParallelPhaseControls enableParallelPhaseControls

View File

@ -238,6 +238,76 @@ export default function TrainingFrameworkProgramEditPage() {
const [targetGroupsCatalog, setTargetGroupsCatalog] = useState([]) const [targetGroupsCatalog, setTargetGroupsCatalog] = useState([])
const [sectionPickerCtx, setSectionPickerCtx] = useState(null) const [sectionPickerCtx, setSectionPickerCtx] = useState(null)
const [peekCtx, setPeekCtx] = useState(null) const [peekCtx, setPeekCtx] = useState(null)
const frameworkPlanningContext = useMemo(() => {
if (!sectionPickerCtx) return null
const { slotIdx, sectionIndex: sIdx, itemIndex: iIdx, insertBeforeIndex: beforeIx } = sectionPickerCtx
const slot = form.slots?.[slotIdx]
const secs = slot?.sections?.length ? slot.sections : [defaultSection('Ablauf')]
const sec = secs[sIdx]
let anchorExerciseId = null
if (sec?.items?.length) {
if (typeof iIdx === 'number') {
const item = sec.items[iIdx]
if (item?.exercise_id) {
anchorExerciseId = Number(item.exercise_id)
} else {
for (let i = iIdx - 1; i >= 0; i -= 1) {
if (sec.items[i]?.exercise_id) {
anchorExerciseId = Number(sec.items[i].exercise_id)
break
}
}
}
} else if (typeof beforeIx === 'number') {
for (let i = beforeIx - 1; i >= 0; i -= 1) {
if (sec.items[i]?.exercise_id) {
anchorExerciseId = Number(sec.items[i].exercise_id)
break
}
}
} else {
for (let i = sec.items.length - 1; i >= 0; i -= 1) {
if (sec.items[i]?.exercise_id) {
anchorExerciseId = Number(sec.items[i].exercise_id)
break
}
}
}
}
const plannedExerciseIds = []
const seenPlan = new Set()
for (const s of secs) {
for (const it of s?.items || []) {
if (String(it?.item_type || '').toLowerCase() === 'note') continue
const eid = Number(it?.exercise_id)
if (!Number.isFinite(eid) || eid < 1 || seenPlan.has(eid)) continue
seenPlan.add(eid)
plannedExerciseIds.push(eid)
}
}
return {
unitId: null,
groupId: null,
sectionOrderIndex: sIdx,
anchorExerciseId: Number.isFinite(anchorExerciseId) && anchorExerciseId > 0 ? anchorExerciseId : null,
progressionGraphId: null,
plannedExerciseIds,
}
}, [sectionPickerCtx, form.slots])
const openFrameworkExercisePicker = useCallback((slotIdx, ctx, pickerMode = 'library') => {
setSectionPickerCtx({
slotIdx,
sectionIndex: ctx.sectionIndex,
itemIndex: typeof ctx.itemIndex === 'number' ? ctx.itemIndex : undefined,
insertBeforeIndex:
typeof ctx.insertBeforeIndex === 'number' && Number.isFinite(ctx.insertBeforeIndex)
? ctx.insertBeforeIndex
: undefined,
pickerMode,
})
}, [])
const [editingGoalIdx, setEditingGoalIdx] = useState(null) const [editingGoalIdx, setEditingGoalIdx] = useState(null)
const [goalMenuGi, setGoalMenuGi] = useState(null) const [goalMenuGi, setGoalMenuGi] = useState(null)
/** Nur schmal: Stammdaten | Plan (Ziele übereinander, darunter Slots) — Desktop: alles sichtbar */ /** Nur schmal: Stammdaten | Plan (Ziele übereinander, darunter Slots) — Desktop: alles sichtbar */
@ -911,17 +981,8 @@ export default function TrainingFrameworkProgramEditPage() {
), ),
})) }))
}} }}
onRequestExercisePick={({ sectionIndex, itemIndex, insertBeforeIndex }) => onRequestExercisePick={(ctx) => openFrameworkExercisePicker(si, ctx, 'library')}
setSectionPickerCtx({ onRequestPlanningExercisePick={(ctx) => openFrameworkExercisePicker(si, ctx, 'planning')}
slotIdx: si,
sectionIndex,
itemIndex: typeof itemIndex === 'number' ? itemIndex : undefined,
insertBeforeIndex:
typeof insertBeforeIndex === 'number' && Number.isFinite(insertBeforeIndex)
? insertBeforeIndex
: undefined,
})
}
onPeekExercise={(id, variantId) => onPeekExercise={(id, variantId) =>
setPeekCtx({ exerciseId: id, variantId: variantId ?? null }) setPeekCtx({ exerciseId: id, variantId: variantId ?? null })
} }
@ -1366,6 +1427,9 @@ export default function TrainingFrameworkProgramEditPage() {
open={sectionPickerCtx != null} open={sectionPickerCtx != null}
multiSelect multiSelect
enableQuickCreateDraft enableQuickCreateDraft
pickerMode={sectionPickerCtx?.pickerMode === 'planning' ? 'planning' : 'library'}
enableFreePlanningSearch
planningContext={frameworkPlanningContext}
onClose={() => setSectionPickerCtx(null)} onClose={() => setSectionPickerCtx(null)}
onSelectExercises={async (picked) => { onSelectExercises={async (picked) => {
if (!sectionPickerCtx || !picked?.length) return if (!sectionPickerCtx || !picked?.length) return

View File

@ -124,9 +124,9 @@ export default function TrainingUnitEditPage() {
const [saveModuleOpen, setSaveModuleOpen] = useState(false) const [saveModuleOpen, setSaveModuleOpen] = useState(false)
const exercisePickerPlanningContext = useMemo(() => { const exercisePickerPlanningContext = useMemo(() => {
if (!exercisePickerTarget) return null
const resolvedUnitId = const resolvedUnitId =
editingUnit?.id ?? (Number.isFinite(unitId) && unitId > 0 ? unitId : null) editingUnit?.id ?? (Number.isFinite(unitId) && unitId > 0 ? unitId : null)
if (!resolvedUnitId) return null
const target = exercisePickerTarget const target = exercisePickerTarget
const secs = formData.sections || [] const secs = formData.sections || []
const sIdx = target?.sIdx ?? 0 const sIdx = target?.sIdx ?? 0
@ -145,6 +145,14 @@ export default function TrainingUnitEditPage() {
} }
} }
} }
} else if (typeof target?.insertBeforeIndex === 'number') {
const beforeIx = target.insertBeforeIndex
for (let i = beforeIx - 1; i >= 0; i -= 1) {
if (sec.items[i]?.exercise_id) {
anchorExerciseId = Number(sec.items[i].exercise_id)
break
}
}
} else { } else {
for (let i = sec.items.length - 1; i >= 0; i -= 1) { for (let i = sec.items.length - 1; i >= 0; i -= 1) {
if (sec.items[i]?.exercise_id) { if (sec.items[i]?.exercise_id) {
@ -165,14 +173,29 @@ export default function TrainingUnitEditPage() {
plannedExerciseIds.push(eid) plannedExerciseIds.push(eid)
} }
} }
const groupIdRaw = Number(formData.group_id)
return { return {
unitId: Number(resolvedUnitId), unitId: resolvedUnitId ? Number(resolvedUnitId) : null,
groupId: Number.isFinite(groupIdRaw) && groupIdRaw > 0 ? groupIdRaw : null,
sectionOrderIndex: sIdx, sectionOrderIndex: sIdx,
anchorExerciseId: Number.isFinite(anchorExerciseId) && anchorExerciseId > 0 ? anchorExerciseId : null, anchorExerciseId: Number.isFinite(anchorExerciseId) && anchorExerciseId > 0 ? anchorExerciseId : null,
progressionGraphId: null, progressionGraphId: null,
plannedExerciseIds, plannedExerciseIds,
} }
}, [editingUnit?.id, unitId, exercisePickerTarget, formData.sections]) }, [editingUnit?.id, unitId, exercisePickerTarget, formData.sections, formData.group_id])
const openExercisePicker = useCallback(({ sectionIndex, itemIndex, insertBeforeIndex, pickerMode = 'library' }) => {
setExercisePickerTarget({
sIdx: sectionIndex,
iIdx: typeof itemIndex === 'number' ? itemIndex : undefined,
insertBeforeIndex:
typeof insertBeforeIndex === 'number' && Number.isFinite(insertBeforeIndex)
? insertBeforeIndex
: undefined,
pickerMode,
})
setExercisePickerOpen(true)
}, [])
const goBack = useCallback(() => { const goBack = useCallback(() => {
goNavReturn(navigate, location, { goNavReturn(navigate, location, {
@ -739,17 +762,8 @@ export default function TrainingUnitEditPage() {
onRequestPublishToFramework={() => editingUnit?.id && setPublishFrameworkOpen(true)} onRequestPublishToFramework={() => editingUnit?.id && setPublishFrameworkOpen(true)}
onRequestSaveAsModule={() => editingUnit?.id && setSaveModuleOpen(true)} onRequestSaveAsModule={() => editingUnit?.id && setSaveModuleOpen(true)}
onRequestTrainingModulePick={(ctx) => void openModuleApplyModal(ctx)} onRequestTrainingModulePick={(ctx) => void openModuleApplyModal(ctx)}
onRequestExercisePick={({ sectionIndex, itemIndex, insertBeforeIndex }) => { onRequestExercisePick={(ctx) => openExercisePicker({ ...ctx, pickerMode: 'library' })}
setExercisePickerTarget({ onRequestPlanningExercisePick={(ctx) => openExercisePicker({ ...ctx, pickerMode: 'planning' })}
sIdx: sectionIndex,
iIdx: typeof itemIndex === 'number' ? itemIndex : undefined,
insertBeforeIndex:
typeof insertBeforeIndex === 'number' && Number.isFinite(insertBeforeIndex)
? insertBeforeIndex
: undefined,
})
setExercisePickerOpen(true)
}}
onPeekExercise={(id, variantId, peekExtras) => onPeekExercise={(id, variantId, peekExtras) =>
setPlanningPeekCtx({ exerciseId: id, variantId: variantId ?? null, peekExtras: peekExtras ?? null }) setPlanningPeekCtx({ exerciseId: id, variantId: variantId ?? null, peekExtras: peekExtras ?? null })
} }
@ -804,6 +818,8 @@ export default function TrainingUnitEditPage() {
multiSelect multiSelect
enableQuickCreateDraft enableQuickCreateDraft
expectPlanningSearch expectPlanningSearch
pickerMode={exercisePickerTarget?.pickerMode === 'planning' ? 'planning' : 'library'}
enableFreePlanningSearch
planningUnitId={ planningUnitId={
editingUnit?.id ?? editingUnit?.id ??
(Number.isFinite(unitId) && unitId > 0 ? unitId : null) (Number.isFinite(unitId) && unitId > 0 ? unitId : null)