Enhance Training Framework Programs with Session Duration and Filtering Features
All checks were successful
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 39s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m29s

- Added SQL aggregations for session duration (min/max) and goal titles in the training framework programs query.
- Updated the TrainingPlanningFrameworkImportModal component to include filtering options for focus areas, training types, and target groups.
- Implemented session duration display in the TrainingFrameworkProgramsListPage, improving user visibility of program details.
- Introduced utility functions for formatting session duration ranges, enhancing the overall user experience in training planning.
This commit is contained in:
Lars 2026-05-20 13:19:40 +02:00
parent 5a8a212f40
commit 9353909fda
7 changed files with 450 additions and 14 deletions

View File

@ -0,0 +1,65 @@
# Rahmenprogramm: Filter, Dauer, Fähigkeiten-Schwerpunkte (Roadmap)
**Stand:** 2026-05-20
**Status:** Phase 1 umgesetzt (Listen + Import-Filter); Phase 23 offen
## Phase 1 (umgesetzt)
### Listen-Anzeige Session-Dauer
- **GET `/api/training-framework-programs`:** `session_duration_min`, `session_duration_max` (aus Blueprint-`training_units.planned_duration_min`), `goal_titles_agg`, ID-Arrays für Katalog-M:N.
- **UI:** Rahmenprogramm-Liste, Trainingsplanung (Einheiten-Liste/Kalender), Import-Dialog (Programm + pro Slot).
### Import-Filter (clientseitig)
- Textsuche (Titel, Beschreibung, Ziele, Katalog-Namen)
- Fokusbereich, Trainingsart, Zielgruppe (Checkboxen, Katalog-API)
- Ziel-Session-Dauer in Minuten (±10 Min Toleranz gegen Min/Max der Slots)
**Grenze:** Entwicklungsziele sind **freie Texte** pro Rahmen (`training_framework_goals.title`), keine kontrollierte Taxonomie → Filter nur Volltext, keine homogene „Ziel-Tags“-Liste.
## Phase 2 (empfohlen, ohne KI)
| Kriterium | Datenquelle heute | Verbesserung |
|-----------|-------------------|--------------|
| Fokusbereich / Stil / Trainingsart / Zielgruppe | M:N am Rahmenkopf | bereits filterbar |
| Entwicklungsziele | Freitext-Ziele | Optional: Ziel-Vorlagen-Katalog oder Tags (Migration) |
| Session-Dauer | `planned_duration_min` pro Slot | erledigt |
| Fähigkeiten-Schwerpunkt | noch nicht | siehe Phase 3 |
**API-Erweiterung (optional):** `GET /api/training-framework-programs?focus_area_id=&training_type_id=&duration_min=` serverseitig — sinnvoll ab >50 Rahmen in der Bibliothek.
## Phase 3 — Fähigkeiten aus Übungen (Schwerpunkte dynamisch)
### Ziel
Aus allen Übungen in allen Slots eines Rahmenprogramms die verknüpften **Fähigkeiten** (`exercise_skills` → `skills`, ggf. Fokusbereich der Fähigkeit) aggregieren, gewichten und als **Vorschlags-Schwerpunkte** oder Metadaten am Rahmen anzeigen (nicht zwingend automatisch in den Kopf schreiben).
### Variante A — Regelbasiert (ohne KI)
1. Pro Blueprint-Unit alle `exercise_id` aus `training_unit_section_items` sammeln.
2. Join `exercise_skills` (optional Gewicht: `planned_duration_min` der Zeile, Anzahl Vorkommen, Primär-Fähigkeit).
3. Top-N Fähigkeiten / Fokusbereiche nach Summe oder Anteil an Gesamtminuten.
4. Ergebnis cachen in `training_framework_programs.skill_profile_json` (Migration) oder nur on-the-fly bei GET Detail.
**Vorteil:** reproduzierbar, offline, Governance-konform.
**Aufwand:** ca. 12 Tage Backend + kleine UI-Karte „Fähigkeiten-Profil (aus Übungen)“.
### Variante B — KI-Zusammenfassung (OpenRouter, optional)
1. Input: Titel Rahmen, Ziele (Text), Liste Übungstitel + Dauer + vorhandene Skill-Namen.
2. Prompt: strukturiertes JSON (`suggested_focus_areas[]`, `skill_emphasis[]`, `rationale_de`).
3. Speichern als `ai_context_summary` (Version, Modell, Timestamp) — **nur Vorschlag**, manuelle Bestätigung vor Übernahme in Stammdaten.
**Vorteil:** natürliche Schwerpunkte auch bei unvollständigen Skill-Links.
**Risiko:** Halluzination, Kosten, Datenschutz (Vereinsdaten in Prompt).
### Empfehlung
Zuerst **Variante A** für Listen/Filter und Abgleich mit manuell gesetzten Fokusbereichen; KI nur als **„Vorschlag generieren“-Button** im Rahmen-Editor, wenn Regelwerk und Katalog-Zuordnung zu dünn sind.
## Offene Produktfragen
1. Soll Filter **UND** (alle Kriterien) oder **ODER** (mindestens eines) sein? — Import aktuell **UND**.
2. Rahmen mit **unterschiedlichen** Slot-Dauern: Liste zeigt MinMax; Filter „90 Min“ trifft Range.
3. Sollen homogenisierte **Entwicklungsziel-Tags** ein eigener Katalog werden (Admin), analog `target_groups`?

View File

@ -444,7 +444,46 @@ def list_training_framework_programs(tenant: TenantContext = Depends(get_tenant_
FROM training_framework_program_target_groups j FROM training_framework_program_target_groups j
JOIN target_groups tg ON tg.id = j.target_group_id JOIN target_groups tg ON tg.id = j.target_group_id
WHERE j.framework_program_id = fp.id WHERE j.framework_program_id = fp.id
) AS target_group_names_agg ) AS target_group_names_agg,
(
SELECT STRING_AGG(g.title::text, ' | ' ORDER BY g.sort_order)
FROM training_framework_goals g
WHERE g.framework_program_id = fp.id
) AS goal_titles_agg,
(
SELECT MIN(tu.planned_duration_min)::int
FROM training_framework_slots fs
INNER JOIN training_units tu ON tu.framework_slot_id = fs.id
WHERE fs.framework_program_id = fp.id
AND tu.planned_duration_min IS NOT NULL
) AS session_duration_min,
(
SELECT MAX(tu.planned_duration_min)::int
FROM training_framework_slots fs
INNER JOIN training_units tu ON tu.framework_slot_id = fs.id
WHERE fs.framework_program_id = fp.id
AND tu.planned_duration_min IS NOT NULL
) AS session_duration_max,
(
SELECT COALESCE(json_agg(j.focus_area_id ORDER BY j.focus_area_id), '[]'::json)
FROM training_framework_program_focus_areas j
WHERE j.framework_program_id = fp.id
) AS focus_area_ids,
(
SELECT COALESCE(json_agg(j.style_direction_id ORDER BY j.style_direction_id), '[]'::json)
FROM training_framework_program_style_directions j
WHERE j.framework_program_id = fp.id
) AS style_direction_ids,
(
SELECT COALESCE(json_agg(j.training_type_id ORDER BY j.training_type_id), '[]'::json)
FROM training_framework_program_training_types j
WHERE j.framework_program_id = fp.id
) AS training_type_ids,
(
SELECT COALESCE(json_agg(j.target_group_id ORDER BY j.target_group_id), '[]'::json)
FROM training_framework_program_target_groups j
WHERE j.framework_program_id = fp.id
) AS target_group_ids
FROM training_framework_programs fp FROM training_framework_programs fp
""" """
vis_clause, vis_params = library_content_visibility_sql( vis_clause, vis_params = library_content_visibility_sql(

View File

@ -1,4 +1,10 @@
import React from 'react' import React, { useMemo, useState } from 'react'
import {
filterFrameworkPrograms,
frameworkProgramOptionLabel,
frameworkSessionDurationLabel,
} from '../../utils/frameworkProgramListHelpers'
import { formatDurationDisplay } from '../../utils/trainingDurationUtils'
/** /**
* Modal: geplante Einheiten aus einem Trainingsrahmenprogramm (Blueprint-Slots) erzeugen. * Modal: geplante Einheiten aus einem Trainingsrahmenprogramm (Blueprint-Slots) erzeugen.
@ -6,6 +12,9 @@ import React from 'react'
export default function TrainingPlanningFrameworkImportModal({ export default function TrainingPlanningFrameworkImportModal({
open, open,
frameworkProgramsList, frameworkProgramsList,
catalogFocusAreas = [],
catalogTrainingTypes = [],
catalogTargetGroups = [],
fwImportProgramId, fwImportProgramId,
onProgramChange, onProgramChange,
fwImportLoading, fwImportLoading,
@ -23,6 +32,41 @@ export default function TrainingPlanningFrameworkImportModal({
onSubmit, onSubmit,
onClose, onClose,
}) { }) {
const [filterQuery, setFilterQuery] = useState('')
const [filterFocusIds, setFilterFocusIds] = useState([])
const [filterTypeIds, setFilterTypeIds] = useState([])
const [filterTargetGroupIds, setFilterTargetGroupIds] = useState([])
const [filterDurationMin, setFilterDurationMin] = useState('')
const filteredPrograms = useMemo(
() =>
filterFrameworkPrograms(frameworkProgramsList, {
query: filterQuery,
focusAreaIds: filterFocusIds,
trainingTypeIds: filterTypeIds,
targetGroupIds: filterTargetGroupIds,
durationTargetMin: filterDurationMin === '' ? null : parseInt(filterDurationMin, 10),
}),
[
frameworkProgramsList,
filterQuery,
filterFocusIds,
filterTypeIds,
filterTargetGroupIds,
filterDurationMin,
]
)
const selectedProgramSummary = useMemo(() => {
if (!fwImportProgramId) return null
return (frameworkProgramsList || []).find((p) => String(p.id) === String(fwImportProgramId))
}, [frameworkProgramsList, fwImportProgramId])
const toggleId = (list, setList, id) => {
const s = String(id)
setList((prev) => (prev.includes(s) ? prev.filter((x) => x !== s) : [...prev, s]))
}
if (!open) return null if (!open) return null
return ( return (
@ -48,7 +92,7 @@ export default function TrainingPlanningFrameworkImportModal({
background: 'var(--surface)', background: 'var(--surface)',
borderRadius: '12px', borderRadius: '12px',
padding: 'clamp(14px, 3vw, 1.75rem)', padding: 'clamp(14px, 3vw, 1.75rem)',
maxWidth: 'min(620px, 100%)', maxWidth: 'min(680px, 100%)',
width: '100%', width: '100%',
maxHeight: '90vh', maxHeight: '90vh',
overflowY: 'auto', overflowY: 'auto',
@ -60,9 +104,104 @@ export default function TrainingPlanningFrameworkImportModal({
<p style={{ color: 'var(--text2)', fontSize: '0.9rem', marginBottom: '1rem', lineHeight: 1.5 }}> <p style={{ color: 'var(--text2)', fontSize: '0.9rem', marginBottom: '1rem', lineHeight: 1.5 }}>
Wähle ein Trainingsrahmenprogramm und eine oder mehrere Sessions. Pro Session entsteht eine{' '} Wähle ein Trainingsrahmenprogramm und eine oder mehrere Sessions. Pro Session entsteht eine{' '}
<strong>eigene geplante Einheit</strong> in der aktuellen Gruppe (Kopie des Ablaufs). Die{' '} <strong>eigene geplante Einheit</strong> in der aktuellen Gruppe (Kopie des Ablaufs). Die{' '}
<strong>Verknüpfung zum Rahmen-Slot</strong> wird gespeichert, damit die Herkunft sichtbar bleibt. <strong>Verknüpfung zum Rahmen-Slot</strong> bleibt sichtbar.
</p> </p>
<details className="planning-filter-help" style={{ marginBottom: '1rem' }}>
<summary className="planning-filter-help__summary">Rahmen filtern (optional)</summary>
<div className="planning-filter-help__body" style={{ display: 'grid', gap: '10px' }}>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">Suche (Titel, Ziele, Katalog)</label>
<input
className="form-input"
value={filterQuery}
onChange={(e) => setFilterQuery(e.target.value)}
placeholder="z. B. Gürtel, Koordination …"
disabled={fwImportSubmitting}
/>
</div>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">Ziel-Session-Dauer (Minuten)</label>
<input
type="number"
min={0}
className="form-input"
value={filterDurationMin}
onChange={(e) => setFilterDurationMin(e.target.value)}
placeholder="z. B. 90"
disabled={fwImportSubmitting}
/>
<p className="form-sub" style={{ marginTop: '4px' }}>
Zeigt Programme, deren hinterlegte Session-Dauer in etwa passt (±10 Min).
</p>
</div>
{catalogFocusAreas.length > 0 ? (
<div>
<span className="form-label" style={{ display: 'block', marginBottom: '6px' }}>
Fokusbereich
</span>
<div className="framework-catalog-checkgrid">
{catalogFocusAreas.map((fa) => (
<label key={fa.id} className="framework-catalog-check">
<input
type="checkbox"
checked={filterFocusIds.includes(String(fa.id))}
onChange={() => toggleId(filterFocusIds, setFilterFocusIds, fa.id)}
disabled={fwImportSubmitting}
/>
<span>{fa.name}</span>
</label>
))}
</div>
</div>
) : null}
{catalogTrainingTypes.length > 0 ? (
<div>
<span className="form-label" style={{ display: 'block', marginBottom: '6px' }}>
Trainingsart
</span>
<div className="framework-catalog-checkgrid">
{catalogTrainingTypes.map((t) => (
<label key={t.id} className="framework-catalog-check">
<input
type="checkbox"
checked={filterTypeIds.includes(String(t.id))}
onChange={() => toggleId(filterTypeIds, setFilterTypeIds, t.id)}
disabled={fwImportSubmitting}
/>
<span>{t.name}</span>
</label>
))}
</div>
</div>
) : null}
{catalogTargetGroups.length > 0 ? (
<div>
<span className="form-label" style={{ display: 'block', marginBottom: '6px' }}>
Zielgruppe
</span>
<div className="framework-catalog-checkgrid">
{catalogTargetGroups.map((tg) => (
<label key={tg.id} className="framework-catalog-check">
<input
type="checkbox"
checked={filterTargetGroupIds.includes(String(tg.id))}
onChange={() => toggleId(filterTargetGroupIds, setFilterTargetGroupIds, tg.id)}
disabled={fwImportSubmitting}
/>
<span>{tg.name}</span>
</label>
))}
</div>
</div>
) : null}
<p className="form-sub" style={{ margin: 0 }}>
{filteredPrograms.length} von {frameworkProgramsList.length} Rahmenprogramm(en) sichtbar.
Entwicklungsziele sind freie Texte die Suche durchsucht auch Ziel-Titel.
</p>
</div>
</details>
<div className="form-row"> <div className="form-row">
<label className="form-label">Rahmenprogramm</label> <label className="form-label">Rahmenprogramm</label>
<select <select
@ -72,12 +211,23 @@ export default function TrainingPlanningFrameworkImportModal({
disabled={fwImportLoading || fwImportSubmitting} disabled={fwImportLoading || fwImportSubmitting}
> >
<option value="">Bitte wählen</option> <option value="">Bitte wählen</option>
{frameworkProgramsList.map((fp) => ( {filteredPrograms.map((fp) => (
<option key={fp.id} value={String(fp.id)}> <option key={fp.id} value={String(fp.id)}>
{(fp.title || '').trim() || `Rahmen #${fp.id}`} {frameworkProgramOptionLabel(fp)}
</option> </option>
))} ))}
</select> </select>
{selectedProgramSummary ? (
<p className="form-sub" style={{ marginTop: '6px' }}>
Session-Dauer: <strong>{frameworkSessionDurationLabel(selectedProgramSummary)}</strong>
{selectedProgramSummary.goal_titles_agg ? (
<>
{' '}
· Ziele: {selectedProgramSummary.goal_titles_agg}
</>
) : null}
</p>
) : null}
</div> </div>
{fwImportLoading ? ( {fwImportLoading ? (
@ -96,6 +246,10 @@ export default function TrainingPlanningFrameworkImportModal({
const checked = fwImportSelectedSlots.has(slot.id) const checked = fwImportSelectedSlots.has(slot.id)
const label = const label =
(slot.title || '').trim() || `Session ${(slot.sort_order ?? 0) + 1}` (slot.title || '').trim() || `Session ${(slot.sort_order ?? 0) + 1}`
const slotDur =
slot.planned_duration_min != null
? formatDurationDisplay(slot.planned_duration_min)
: null
return ( return (
<li key={slot.id} style={{ marginBottom: '10px' }}> <li key={slot.id} style={{ marginBottom: '10px' }}>
<label <label
@ -116,6 +270,18 @@ export default function TrainingPlanningFrameworkImportModal({
/> />
<span style={{ flex: 1, minWidth: 0 }}> <span style={{ flex: 1, minWidth: 0 }}>
<strong>{label}</strong> <strong>{label}</strong>
{slotDur ? (
<span
style={{
marginLeft: '8px',
fontSize: '0.82rem',
color: 'var(--text2)',
fontWeight: 500,
}}
>
· {slotDur}
</span>
) : null}
{!hasBp ? ( {!hasBp ? (
<span style={{ display: 'block', fontSize: '0.82rem', color: 'var(--danger)' }}> <span style={{ display: 'block', fontSize: '0.82rem', color: 'var(--danger)' }}>
Ohne Session-Ablauf Übernahme nicht möglich. Ohne Session-Ablauf Übernahme nicht möglich.

View File

@ -31,6 +31,7 @@ import {
legacyPlanningUnitDeepLinkTarget, legacyPlanningUnitDeepLinkTarget,
parsePlanningHubQuery, parsePlanningHubQuery,
} from '../../utils/planningUnitRoutes' } from '../../utils/planningUnitRoutes'
import { formatDurationDisplay } from '../../utils/trainingDurationUtils'
function TrainingPlanningPageRoot() { function TrainingPlanningPageRoot() {
const { user } = useAuth() const { user } = useAuth()
@ -55,6 +56,9 @@ function TrainingPlanningPageRoot() {
const [frameworkImportOpen, setFrameworkImportOpen] = useState(false) const [frameworkImportOpen, setFrameworkImportOpen] = useState(false)
const [frameworkProgramsList, setFrameworkProgramsList] = useState([]) const [frameworkProgramsList, setFrameworkProgramsList] = useState([])
const [fwImportCatalogFocus, setFwImportCatalogFocus] = useState([])
const [fwImportCatalogTypes, setFwImportCatalogTypes] = useState([])
const [fwImportCatalogTargetGroups, setFwImportCatalogTargetGroups] = useState([])
const [fwImportProgramId, setFwImportProgramId] = useState('') const [fwImportProgramId, setFwImportProgramId] = useState('')
const [fwImportDetail, setFwImportDetail] = useState(null) const [fwImportDetail, setFwImportDetail] = useState(null)
const [fwImportLoading, setFwImportLoading] = useState(false) const [fwImportLoading, setFwImportLoading] = useState(false)
@ -271,12 +275,25 @@ function TrainingPlanningPageRoot() {
let cancelled = false let cancelled = false
;(async () => { ;(async () => {
try { try {
const list = await api.listTrainingFrameworkPrograms() const [list, fa, tt, tg] = await Promise.all([
if (!cancelled) setFrameworkProgramsList(Array.isArray(list) ? list : []) api.listTrainingFrameworkPrograms(),
api.listFocusAreas({ status: 'active' }),
api.listTrainingTypes({ status: 'active' }),
api.listTargetGroups({ status: 'active' }),
])
if (!cancelled) {
setFrameworkProgramsList(Array.isArray(list) ? list : [])
setFwImportCatalogFocus(Array.isArray(fa) ? fa : [])
setFwImportCatalogTypes(Array.isArray(tt) ? tt : [])
setFwImportCatalogTargetGroups(Array.isArray(tg) ? tg : [])
}
} catch (e) { } catch (e) {
if (!cancelled) { if (!cancelled) {
console.error('Rahmenprogramme laden:', e) console.error('Rahmenprogramme laden:', e)
setFrameworkProgramsList([]) setFrameworkProgramsList([])
setFwImportCatalogFocus([])
setFwImportCatalogTypes([])
setFwImportCatalogTargetGroups([])
} }
} }
})() })()
@ -1033,7 +1050,9 @@ function TrainingPlanningPageRoot() {
onClick={() => handleEdit(unit)} onClick={() => handleEdit(unit)}
title={[ title={[
planScope === 'club' && unit.group_name ? unit.group_name : '', planScope === 'club' && unit.group_name ? unit.group_name : '',
unit.planned_time_start?.slice(0, 5) || '', unit.planned_duration_min
? formatDurationDisplay(unit.planned_duration_min)
: unit.planned_time_start?.slice(0, 5) || '',
unit.lead_trainer_name?.trim(), unit.lead_trainer_name?.trim(),
unit.planned_focus?.trim(), unit.planned_focus?.trim(),
unit.status === 'completed' unit.status === 'completed'
@ -1066,9 +1085,11 @@ function TrainingPlanningPageRoot() {
}} }}
> >
<span style={{ fontWeight: 600 }}> <span style={{ fontWeight: 600 }}>
{unit.planned_time_start {unit.planned_duration_min
? `${unit.planned_time_start.slice(0, 5)}` ? formatDurationDisplay(unit.planned_duration_min)
: 'Ganztags'} : unit.planned_time_start
? `${unit.planned_time_start.slice(0, 5)}`
: 'Ganztags'}
</span> </span>
{planScope === 'club' && (unit.group_name || '').trim() ? ( {planScope === 'club' && (unit.group_name || '').trim() ? (
<span style={{ display: 'block', fontWeight: 500, color: 'var(--text1)' }}> <span style={{ display: 'block', fontWeight: 500, color: 'var(--text1)' }}>
@ -1158,11 +1179,17 @@ function TrainingPlanningPageRoot() {
<h3 style={{ marginBottom: '0.25rem' }}> <h3 style={{ marginBottom: '0.25rem' }}>
{unit.planned_date} {unit.planned_date}
{unit.planned_duration_min {unit.planned_duration_min
? ` · ${unit.planned_duration_min >= 60 && unit.planned_duration_min % 60 === 0 ? `${unit.planned_duration_min / 60} h` : `${unit.planned_duration_min} Min`}` ? ` · ${formatDurationDisplay(unit.planned_duration_min)}`
: unit.planned_time_start : unit.planned_time_start
? ` · ${unit.planned_time_start.slice(0, 5)} - ${unit.planned_time_end?.slice(0, 5)}` ? ` · ${unit.planned_time_start.slice(0, 5)}${unit.planned_time_end ? ` ${unit.planned_time_end.slice(0, 5)}` : ''}`
: ''} : ''}
</h3> </h3>
{unit.planned_duration_min && unit.planned_time_start ? (
<p style={{ fontSize: '0.78rem', color: 'var(--text3)', margin: '0 0 0.35rem' }}>
Uhrzeit: {unit.planned_time_start.slice(0, 5)}
{unit.planned_time_end ? ` ${unit.planned_time_end.slice(0, 5)}` : ''}
</p>
) : null}
{planScope === 'club' && (unit.group_name || '').trim() ? ( {planScope === 'club' && (unit.group_name || '').trim() ? (
<p <p
style={{ style={{
@ -1378,6 +1405,9 @@ function TrainingPlanningPageRoot() {
<TrainingPlanningFrameworkImportModal <TrainingPlanningFrameworkImportModal
open={frameworkImportOpen} open={frameworkImportOpen}
frameworkProgramsList={frameworkProgramsList} frameworkProgramsList={frameworkProgramsList}
catalogFocusAreas={fwImportCatalogFocus}
catalogTrainingTypes={fwImportCatalogTypes}
catalogTargetGroups={fwImportCatalogTargetGroups}
fwImportProgramId={fwImportProgramId} fwImportProgramId={fwImportProgramId}
onProgramChange={onFwImportProgramChange} onProgramChange={onFwImportProgramChange}
fwImportLoading={fwImportLoading} fwImportLoading={fwImportLoading}

View File

@ -4,6 +4,7 @@ import NavStateLink from '../components/NavStateLink'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { getTenantClubDependencyKey } from '../utils/activeClub' import { getTenantClubDependencyKey } from '../utils/activeClub'
import { buildFrameworkProgramsListReturnContext } from '../utils/navReturnContext' import { buildFrameworkProgramsListReturnContext } from '../utils/navReturnContext'
import { frameworkSessionDurationLabel } from '../utils/frameworkProgramListHelpers'
function dashIfEmpty(val) { function dashIfEmpty(val) {
const s = (val ?? '').toString().trim() const s = (val ?? '').toString().trim()
@ -37,8 +38,22 @@ function FrameworkSummaryMeta({ r }) {
lineHeight: 1.45, lineHeight: 1.45,
} }
const durationLabel = frameworkSessionDurationLabel(r)
const goals =
typeof r.goal_titles_agg === 'string' ? r.goal_titles_agg.trim() : ''
return ( return (
<dl style={{ margin: '0.5rem 0 0', padding: 0, fontSize: '0.875rem', color: 'var(--text2)' }}> <dl style={{ margin: '0.5rem 0 0', padding: 0, fontSize: '0.875rem', color: 'var(--text2)' }}>
<div style={rowStyle}>
<dt style={{ margin: 0, fontWeight: 600, color: 'var(--text3)' }}>Session-Dauer</dt>
<dd style={{ margin: 0 }}>{durationLabel}</dd>
</div>
{goals ? (
<div style={rowStyle}>
<dt style={{ margin: 0, fontWeight: 600, color: 'var(--text3)' }}>Entwicklungsziele</dt>
<dd style={{ margin: 0 }}>{goals}</dd>
</div>
) : null}
<div style={rowStyle}> <div style={rowStyle}>
<dt style={{ margin: 0, fontWeight: 600, color: 'var(--text3)' }}>Fokusbereich</dt> <dt style={{ margin: 0, fontWeight: 600, color: 'var(--text3)' }}>Fokusbereich</dt>
<dd style={{ margin: 0 }}>{dashIfEmpty(focus)}</dd> <dd style={{ margin: 0 }}>{dashIfEmpty(focus)}</dd>
@ -187,6 +202,19 @@ export default function TrainingFrameworkProgramsListPage() {
{r.title || `Rahmen #${r.id}`} {r.title || `Rahmen #${r.id}`}
</NavStateLink> </NavStateLink>
<div style={{ fontSize: '0.85rem', color: 'var(--text2)', marginTop: '0.35rem' }}> <div style={{ fontSize: '0.85rem', color: 'var(--text2)', marginTop: '0.35rem' }}>
<span
style={{
display: 'inline-block',
marginRight: '8px',
padding: '2px 8px',
borderRadius: '6px',
background: 'var(--surface2)',
fontWeight: 600,
color: 'var(--text1)',
}}
>
{frameworkSessionDurationLabel(r)}
</span>
<span> <span>
{(r.goals_count ?? '—') + ' Ziele · '} {(r.goals_count ?? '—') + ' Ziele · '}
{(r.slots_count ?? '—') + ' Slots'} {(r.slots_count ?? '—') + ' Slots'}

View File

@ -0,0 +1,88 @@
import { formatSessionDurationRange } from './trainingDurationUtils'
export function frameworkSessionDurationLabel(row) {
return formatSessionDurationRange(row?.session_duration_min, row?.session_duration_max, {
empty: 'Dauer nicht angegeben',
})
}
function parseIdList(raw) {
if (Array.isArray(raw)) {
return raw.map((x) => String(x)).filter(Boolean)
}
if (typeof raw === 'string' && raw.trim().startsWith('[')) {
try {
const arr = JSON.parse(raw)
if (Array.isArray(arr)) return arr.map((x) => String(x))
} catch {
/* ignore */
}
}
return []
}
function rowMatchesDuration(row, targetMin, toleranceMin = 10) {
if (targetMin == null || targetMin === '' || Number.isNaN(Number(targetMin))) return true
const t = Number(targetMin)
const lo = row.session_duration_min != null ? Number(row.session_duration_min) : null
const hi = row.session_duration_max != null ? Number(row.session_duration_max) : null
if (lo == null && hi == null) return true
if (lo != null && hi != null) {
return t >= lo - toleranceMin && t <= hi + toleranceMin
}
const single = lo ?? hi
return single != null && Math.abs(single - t) <= toleranceMin
}
/**
* Client-Filter für Rahmenprogramm-Auswahl (Liste / Import-Dialog).
*/
export function filterFrameworkPrograms(rows, filters = {}) {
const q = (filters.query || '').trim().toLowerCase()
const focusIds = new Set((filters.focusAreaIds || []).map(String))
const typeIds = new Set((filters.trainingTypeIds || []).map(String))
const tgIds = new Set((filters.targetGroupIds || []).map(String))
const durationTarget = filters.durationTargetMin
return (rows || []).filter((r) => {
if (q) {
const blob = [
r.title,
r.description,
r.goal_titles_agg,
r.focus_area_names_agg,
r.style_direction_names_agg,
r.training_type_names_agg,
r.target_group_names_agg,
]
.filter(Boolean)
.join(' ')
.toLowerCase()
if (!blob.includes(q)) return false
}
if (focusIds.size) {
const fa = parseIdList(r.focus_area_ids)
if (!fa.some((id) => focusIds.has(id))) return false
}
if (typeIds.size) {
const tt = parseIdList(r.training_type_ids)
if (!tt.some((id) => typeIds.has(id))) return false
}
if (tgIds.size) {
const tg = parseIdList(r.target_group_ids)
if (!tg.some((id) => tgIds.has(id))) return false
}
if (!rowMatchesDuration(r, durationTarget)) return false
return true
})
}
export function frameworkProgramOptionLabel(row) {
const title = (row?.title || '').trim() || `Rahmen #${row?.id}`
const dur = frameworkSessionDurationLabel(row)
const slots = row?.slots_count != null ? `${row.slots_count} Slot(s)` : ''
const bits = [dur !== 'Dauer nicht angegeben' ? dur : null, slots].filter(Boolean)
return bits.length ? `${title} · ${bits.join(' · ')}` : title
}

View File

@ -80,3 +80,23 @@ export function sumSectionPlannedMinutes(sections) {
} }
return sum return sum
} }
/**
* Anzeige für Session-Dauer (ein Slot oder Min/Max über mehrere Sessions).
* @param {number|null|undefined} minMinutes
* @param {number|null|undefined} maxMinutes
*/
export function formatSessionDurationRange(minMinutes, maxMinutes, { empty = '—' } = {}) {
const lo = minMinutes != null && minMinutes !== '' ? Number(minMinutes) : null
const hi = maxMinutes != null && maxMinutes !== '' ? Number(maxMinutes) : null
if (lo != null && Number.isFinite(lo) && lo > 0) {
if (hi != null && Number.isFinite(hi) && hi > 0 && hi !== lo) {
return `${formatDurationDisplay(lo, { empty: '' })} ${formatDurationDisplay(hi, { empty: '' })}`
}
return formatDurationDisplay(lo, { empty })
}
if (hi != null && Number.isFinite(hi) && hi > 0) {
return formatDurationDisplay(hi, { empty })
}
return empty
}