Increment application version to 0.8.164 and update changelog for new features in ExercisePickerModal
All checks were successful
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 40s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m15s

- Updated APP_VERSION to 0.8.164 and added changelog entry for the new version.
- Enhanced ExercisePickerModal to support quick exercise creation using AI, including fields for sketch and focus area.
- Implemented error handling for AI suggestions and improved user prompts for input validation.
- Updated UI elements to reflect changes in exercise creation workflow.
This commit is contained in:
Lars 2026-05-22 19:01:01 +02:00
parent 9f4678f418
commit 4725eaa90b
3 changed files with 194 additions and 38 deletions

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.163"
APP_VERSION = "0.8.164"
BUILD_DATE = "2026-05-31"
DB_SCHEMA_VERSION = "20260531071"
@ -42,6 +42,13 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.164",
"date": "2026-05-31",
"changes": [
"Planung/Übungspicker: Schnellanlage nutzt suggestExerciseAi (Anleitung, Kurzbeschreibung, Fähigkeiten); Fokusbereich Pflichtfeld.",
],
},
{
"version": "0.8.163",
"date": "2026-05-31",

View File

@ -17,16 +17,13 @@ import {
import SkillTreeMultiSelect from './SkillTreeMultiSelect'
import ExerciseFocusRulePicker from './ExerciseFocusRulePicker'
import CatalogRulePicker from './CatalogRulePicker'
import { buildQuickCreateExercisePayload } from '../utils/exerciseAiQuickCreate'
const PAGE_SIZE = 100
const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null)
const INITIAL_FILTERS = { ...INITIAL_EXERCISE_LIST_FILTERS }
/** Stub-Ziel für API-Validator (mind. Ziel oder Durchführung); Nutzer ergänzt Details in der Übungsbearbeitung. */
const QUICK_CREATE_GOAL_PLACEHOLDER =
'Aus der Trainingsplanung angelegt — bitte Ziel und Durchführung in der Übungsbearbeitung ergänzen.'
export default function ExercisePickerModal({
open,
onClose,
@ -59,8 +56,10 @@ export default function ExercisePickerModal({
const [multiPicked, setMultiPicked] = useState([])
const [quickOpen, setQuickOpen] = useState(false)
const [quickTitle, setQuickTitle] = useState('')
const [quickSummary, setQuickSummary] = useState('')
const [quickSketch, setQuickSketch] = useState('')
const [quickFocusAreaId, setQuickFocusAreaId] = useState('')
const [quickSaving, setQuickSaving] = useState(false)
const [quickAiError, setQuickAiError] = useState('')
const pickerScrollRef = useRef(null)
const toggleMultiPick = (ex) => {
@ -124,8 +123,10 @@ export default function ExercisePickerModal({
setMultiPicked([])
setQuickOpen(false)
setQuickTitle('')
setQuickSummary('')
setQuickSketch('')
setQuickFocusAreaId('')
setQuickSaving(false)
setQuickAiError('')
return
}
setFilters(mergeExerciseListPrefsFromApi(user?.exercise_list_prefs))
@ -291,25 +292,44 @@ export default function ExercisePickerModal({
alert('Titel: mindestens 3 Zeichen.')
return
}
const summaryRaw = (quickSummary || '').trim()
const sketch = (quickSketch || '').trim()
if (!sketch) {
alert('Bitte eine kurze Skizze / Idee eingeben — die KI erzeugt daraus die Anleitung.')
return
}
const focusId = parseInt(String(quickFocusAreaId).trim(), 10)
if (!Number.isFinite(focusId) || focusId < 1) {
alert('Bitte einen Fokusbereich wählen (für Fähigkeiten-Vorschläge).')
return
}
const focusRow = (catalogs.focusAreas || []).find((x) => Number(x.id) === focusId)
const focusHint = (focusRow?.name || '').trim()
setQuickAiError('')
setQuickSaving(true)
try {
const created = await api.createExercise({
const aiRes = await api.suggestExerciseAi({
title,
summary: summaryRaw || null,
goal: QUICK_CREATE_GOAL_PLACEHOLDER,
execution: null,
visibility: 'private',
status: 'draft',
equipment: [],
focus_areas_multi: [],
training_styles_multi: [],
training_types_multi: [],
target_groups_multi: [],
age_groups: [],
skills: [],
club_id: null,
goal: sketch,
execution: '',
preparation: '',
trainer_notes: '',
focus_area_hint: focusHint || undefined,
focus_areas_context: [{ focus_area_id: focusId, is_primary: true }],
include_summary: true,
include_skills: true,
include_instructions: true,
})
const payload = buildQuickCreateExercisePayload({
title,
focusAreaId: focusId,
sketchPlain: sketch,
apiRes: aiRes,
})
const created = await api.createExercise(payload)
if (!created?.id) {
throw new Error('Anlegen fehlgeschlagen')
}
@ -321,7 +341,9 @@ export default function ExercisePickerModal({
onClose()
} catch (e) {
console.error(e)
alert(e.message || 'Übung konnte nicht angelegt werden')
const msg = e?.message || String(e)
setQuickAiError(msg)
alert(msg || 'Übung konnte nicht angelegt werden')
} finally {
setQuickSaving(false)
}
@ -369,18 +391,18 @@ export default function ExercisePickerModal({
onClick={() => setQuickOpen((v) => !v)}
aria-expanded={quickOpen}
>
{quickOpen ? 'Neue Übung ausblenden' : 'Neue Übung anlegen (Entwurf, privat)'}
{quickOpen ? 'Neue Übung ausblenden' : 'Neue Übung mit KI anlegen'}
</button>
{quickOpen ? (
{quickOpen ? (
<div style={{ marginTop: '12px', display: 'grid', gap: '10px' }}>
<p style={{ margin: 0, fontSize: '13px', color: 'var(--text2)', lineHeight: 1.45 }}>
Wird mit Freigabelevel <strong>privat</strong> und Status <strong>Entwurf</strong> gespeichert und
erscheint auf dem Dashboard zum Weiterbearbeiten. Nach dem Speichern wird die Übung direkt in den
Ablauf übernommen.
Die KI erzeugt aus Titel und Skizze <strong>Anleitung</strong> (Ziel, Durchführung, Vorbereitung,
Trainer-Hinweise), eine <strong>Kurzbeschreibung</strong> und <strong>Fähigkeiten</strong>. Gespeichert
als Entwurf (privat) und direkt in den Ablauf übernommen. Benötigt OpenRouter auf dem Server.
</p>
<div>
<label className="form-label" htmlFor="ex-picker-quick-title">
Titel
Titel *
</label>
<input
id="ex-picker-quick-title"
@ -395,26 +417,53 @@ export default function ExercisePickerModal({
/>
</div>
<div>
<label className="form-label" htmlFor="ex-picker-quick-summary">
Kurzbeschreibung
<label className="form-label" htmlFor="ex-picker-quick-focus">
Fokusbereich *
</label>
<select
id="ex-picker-quick-focus"
className="form-input"
value={quickFocusAreaId}
onChange={(e) => setQuickFocusAreaId(e.target.value)}
disabled={!catalogsReady}
>
<option value=""> wählen </option>
{(catalogs.focusAreas || []).map((fa) => (
<option key={fa.id} value={String(fa.id)}>
{`${fa.icon ? `${fa.icon} ` : ''}${fa.name || `#${fa.id}`}`.trim()}
</option>
))}
</select>
</div>
<div>
<label className="form-label" htmlFor="ex-picker-quick-sketch">
Skizze / Idee *
</label>
<textarea
id="ex-picker-quick-summary"
id="ex-picker-quick-sketch"
className="form-input"
rows={3}
value={quickSummary}
onChange={(e) => setQuickSummary(e.target.value)}
placeholder="Optional: grobe Idee, Kontext aus der Planung …"
rows={4}
value={quickSketch}
onChange={(e) => setQuickSketch(e.target.value)}
placeholder="Kurze Beschreibung: Ablauf, Material, Ziel der Übung …"
/>
</div>
{quickAiError ? (
<p style={{ margin: 0, fontSize: '13px', color: 'var(--danger)' }}>{quickAiError}</p>
) : null}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', justifyContent: 'flex-end' }}>
<button
type="button"
className="btn btn-primary"
disabled={quickSaving || (quickTitle || '').trim().length < 3}
disabled={
quickSaving ||
(quickTitle || '').trim().length < 3 ||
!(quickSketch || '').trim() ||
!quickFocusAreaId
}
onClick={submitQuickCreate}
>
{quickSaving ? 'Wird angelegt…' : 'Entwurf anlegen und übernehmen'}
{quickSaving ? 'KI erzeugt Übung…' : 'Mit KI anlegen und übernehmen'}
</button>
</div>
</div>

View File

@ -0,0 +1,100 @@
/**
* KI-gestützte Schnellanlage einer Übung (Planung / ExercisePickerModal).
*/
import { stripHtmlToText } from './htmlUtils'
import { normalizeSkillLevelSlug } from '../constants/skillLevels'
import {
EXERCISE_SKILL_INTENSITY_DEFAULT,
normalizeExerciseSkillIntensity,
} from '../constants/exerciseSkillIntensity'
function escapeHtmlText(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
/** Plaintext → Absätze für RichTextEditor / API. */
export function aiPlainTextToMinimalHtml(text) {
const raw = String(text || '').trim()
if (!raw) return ''
const parts = raw.split(/\n+/).map((p) => p.trim()).filter(Boolean)
const paras = parts.length ? parts : [raw]
return paras.map((p) => `<p>${escapeHtmlText(p)}</p>`).join('')
}
export function normalizeAiSkillRowFromApi(sug) {
const sid = Number(sug?.skill_id)
if (!Number.isFinite(sid) || sid < 1) return null
return {
skill_id: sid,
intensity: normalizeExerciseSkillIntensity(sug.intensity) || EXERCISE_SKILL_INTENSITY_DEFAULT,
required_level: normalizeSkillLevelSlug(sug.required_level) || 'grundlagen',
target_level:
normalizeSkillLevelSlug(sug.target_level) ||
normalizeSkillLevelSlug(sug.required_level) ||
'grundlagen',
is_primary: !!sug.is_primary,
ai_suggested: true,
}
}
/**
* Baut createExercise-Payload aus suggestExerciseAi-Antwort.
* @throws {Error} wenn Ziel/Durchführung nach KI leer wären
*/
export function buildQuickCreateExercisePayload({ title, focusAreaId, sketchPlain, apiRes }) {
const sketchHtml = aiPlainTextToMinimalHtml(sketchPlain)
const fields = apiRes?.instructions?.fields || {}
let goal = (fields.goal || '').trim() || sketchHtml
let execution = (fields.execution || '').trim()
const prep = (fields.preparation || '').trim() || null
const trainerNotes = (fields.trainer_notes || '').trim() || null
if (!stripHtmlToText(goal).trim() && !stripHtmlToText(execution).trim()) {
throw new Error('KI lieferte keine verwertbare Anleitung (Ziel/Durchführung leer).')
}
if (!stripHtmlToText(goal).trim()) goal = execution
if (!stripHtmlToText(execution).trim()) execution = goal
let summary = null
if (apiRes?.summary?.text) {
const sumHtml = aiPlainTextToMinimalHtml(apiRes.summary.text)
if (sumHtml) summary = sumHtml
}
const skills = []
if (Array.isArray(apiRes?.skills)) {
for (const sug of apiRes.skills) {
const row = normalizeAiSkillRowFromApi(sug)
if (row) skills.push(row)
}
}
const fid = Number(focusAreaId)
if (!Number.isFinite(fid) || fid < 1) {
throw new Error('Fokusbereich ist erforderlich.')
}
return {
title: (title || '').trim(),
summary,
goal,
execution,
preparation: prep,
trainer_notes: trainerNotes,
visibility: 'private',
status: 'draft',
equipment: [],
focus_areas_multi: [{ focus_area_id: fid, is_primary: true }],
training_styles_multi: [],
training_types_multi: [],
target_groups_multi: [],
age_groups: [],
skills,
club_id: null,
}
}