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
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:
parent
9f4678f418
commit
4725eaa90b
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
100
frontend/src/utils/exerciseAiQuickCreate.js
Normal file
100
frontend/src/utils/exerciseAiQuickCreate.js
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
/** 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,
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user