feat(profiles): add training planning preferences to user profile
All checks were successful
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Successful in 35s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 11s
Test Suite / playwright-tests (push) Successful in 56s

- Introduced `training_planning_prefs` field in the ProfileUpdate model to store user-specific UI options for training planning.
- Updated the backend to handle the new preferences during profile updates, ensuring proper validation and storage.
- Enhanced the frontend to allow users to select their preferred display mode for training modules in the Account Settings page.
- Updated version to 0.8.98 and adjusted database schema version accordingly, reflecting the new feature integration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-05-12 22:36:19 +02:00
parent 05042ee9ec
commit f4f5642c21
7 changed files with 140 additions and 20 deletions

View File

@ -0,0 +1,3 @@
-- Persönliche Planungs-UI-Präferenzen (JSONB, selbst vom Nutzer setzbar)
ALTER TABLE profiles
ADD COLUMN IF NOT EXISTS training_planning_prefs JSONB NOT NULL DEFAULT '{}'::jsonb;

View File

@ -47,6 +47,10 @@ class ProfileUpdate(BaseModel):
default=None,
description="JSON: gespeicherte Standardfilter für die Übungsliste",
)
training_planning_prefs: Optional[Dict[str, Any]] = Field(
default=None,
description="JSON: UI-Optionen Trainingsplanung (z.B. Darstellung kopierter Module)",
)
class ProfileResponse(BaseModel):
id: int

View File

@ -384,6 +384,15 @@ def _run_profile_update(pid: str, p: ProfileUpdate, tenant: TenantContext) -> di
else:
raise HTTPException(400, "exercise_list_prefs muss ein JSON-Objekt sein")
if "training_planning_prefs" in patch:
tp = patch.pop("training_planning_prefs")
if tp is None:
data["training_planning_prefs"] = Json({})
elif isinstance(tp, dict):
data["training_planning_prefs"] = Json(tp)
else:
raise HTTPException(400, "training_planning_prefs muss ein JSON-Objekt sein")
nullable_keys = {"goal_weight", "goal_bf_pct", "dob"}
for k, v in patch.items():
if k == "email":

View File

@ -1,13 +1,13 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.97"
APP_VERSION = "0.8.98"
BUILD_DATE = "2026-05-12"
DB_SCHEMA_VERSION = "20260512054"
DB_SCHEMA_VERSION = "20260512055"
MODULE_VERSIONS = {
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
"auth": "1.2.3", # P-05b: reset-password min_length=8 via Pydantic PasswordResetConfirm
"profiles": "1.7.0", # exercise_list_prefs JSONB (Standard Übungsfilter); Patch via ProfileUpdate + Json()
"profiles": "1.8.0", # training_planning_prefs JSONB (Planungs-UI); Patch via ProfileUpdate + Json(), Migration 055
"tenant_context": "1.0.5", # Plattform-Admin: effective_club ohne Header aus Profil active_club_id wenn Verein existiert
"clubs": "0.4.1", # Alle geschützten Endpoints Depends(get_tenant_context); profile_id/role aus TenantContext
"club_memberships": "1.0.1", # Depends(get_tenant_context)
@ -35,6 +35,13 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.98",
"date": "2026-05-12",
"changes": [
"profiles: `training_planning_prefs` JSONB (Migration 055), Patch via PUT Profil — z.B. Darstellung kopierter Trainingsmodule in der Planungs-UI (nutzerspezifisch).",
],
},
{
"version": "0.8.97",
"date": "2026-05-12",

View File

@ -6,7 +6,8 @@ import {
noteRow,
sectionPlannedMinutes,
} from '../utils/trainingUnitSectionsForm'
import { PLANNING_MODULE_UX_MODE } from '../config/planningModuleUx'
import { isCompactTagLegendMode } from '../config/planningModuleUx'
import { useAuth } from '../context/AuthContext'
const DND_TU_ITEM = 'application/x-shinkan-training-unit-item'
const DND_TU_SECTION = 'application/x-shinkan-training-section-v1'
@ -56,8 +57,6 @@ function gatherPlanningModuleOutline(items, startIdx, moduleId) {
const MODULE_OUTLINE_PREVIEW_MAX = 8
const PLANNING_USE_COMPACT_LEGEND = PLANNING_MODULE_UX_MODE === 'compact_tag_legend'
/** Stabile Farbzurodnung aus Modul-ID (nur Darstellung). */
function planningModulePalette(moduleId) {
const id = normalizedPlanningModuleChainId(moduleId)
@ -178,6 +177,11 @@ export default function TrainingUnitSectionsEditor({
/** Dünnes „+“ zwischen Einträge: Popup für Typ (Übung, Modul, …) */
betweenInsertMenus = true,
}) {
const { user } = useAuth()
const planningCompactLegend = isCompactTagLegendMode(
user?.training_planning_prefs?.module_display_mode
)
const ensure = (prev) =>
prev && prev.length ? prev : [defaultSection()]
@ -571,7 +575,7 @@ export default function TrainingUnitSectionsEditor({
{list.map((sec, sIdx) => {
const planMin = sectionPlannedMinutes(sec)
const itemCount = sec.items?.length ?? 0
const moduleLegend = PLANNING_USE_COMPACT_LEGEND ? sectionModuleLegendModel(sec.items) : []
const moduleLegend = planningCompactLegend ? sectionModuleLegendModel(sec.items) : []
const bandActiveBefore = (bx) =>
enableSectionDragReorder &&
dropSectionBand &&
@ -713,19 +717,19 @@ export default function TrainingUnitSectionsEditor({
(curMn != null ? `Modul #${curMn}` : '')
const modOutline =
!PLANNING_USE_COMPACT_LEGEND &&
!planningCompactLegend &&
showModuleBand &&
curMn != null
? gatherPlanningModuleOutline(sec.items, iIdx, curMn)
: null
const fromModClass =
curMn != null
? PLANNING_USE_COMPACT_LEGEND
? planningCompactLegend
? ' tu-item-row--from-module-soft'
: ' tu-item-row--from-module'
: ''
const modBorderVarStyle =
PLANNING_USE_COMPACT_LEGEND && curMn != null
planningCompactLegend && curMn != null
? { '--tu-mod-border': planningModulePalette(curMn).border }
: undefined
@ -735,7 +739,7 @@ export default function TrainingUnitSectionsEditor({
const noteHasText = Boolean((it.note_body || '').trim()) && !isSepLine
return (
<Fragment key={`${insertSlotKeyPrefix}sec-${sIdx}-blk-${iIdx}`}>
{!PLANNING_USE_COMPACT_LEGEND &&
{!planningCompactLegend &&
renderModulePlanningHead(modBandTitle, modOutline, showModuleBand)}
<div
className={
@ -778,7 +782,7 @@ export default function TrainingUnitSectionsEditor({
</button>
</div>
<div className="tu-item-row__body tu-item-row__body--note">
{!isSepLine && PLANNING_USE_COMPACT_LEGEND && curMn ? (
{!isSepLine && planningCompactLegend && curMn ? (
<PlanningModuleRowTag moduleId={curMn} title={modBandTitle} />
) : null}
<span className="tu-item-row__meta-label">
@ -843,7 +847,7 @@ export default function TrainingUnitSectionsEditor({
return (
<Fragment key={`${insertSlotKeyPrefix}sec-${sIdx}-blk-${iIdx}`}>
{!PLANNING_USE_COMPACT_LEGEND &&
{!planningCompactLegend &&
renderModulePlanningHead(modBandTitle, modOutline, showModuleBand)}
<div
className={`${rowCommon} tu-item-row--exercise${fromModClass}`}
@ -889,7 +893,7 @@ export default function TrainingUnitSectionsEditor({
) : (
<span className="tu-ex-title-placeholder">Keine Übung gewählt</span>
)}
{PLANNING_USE_COMPACT_LEGEND && curMn ? (
{planningCompactLegend && curMn ? (
<PlanningModuleRowTag moduleId={curMn} title={modBandTitle} />
) : null}
<span className="tu-ex-inline-actions">

View File

@ -1,11 +1,26 @@
/**
* Darstellung Herkunft Trainingsmodul in Abschnitten (Planungs-Editor).
*
* - compact_tag_legend (Standard): wenig Höhe farbige Leiste am Eintrag,
* kleiner Modul-Tag in der Zeile, Legende pro Abschnitt unten (Farbe Modul).
* - full_outline_headers: früheres Verhalten mit großem Kopf-Bereich inkl.
* Auflistung der Übungen (viel Platz, maximale Orientierung ohne Scroll).
* Der Modus kommt aus dem Profil (`training_planning_prefs.module_display_mode`).
* Standard: kompakt (Tags + Legende). Voll: großer Kopf mit Auflistung der Übungen.
*
* Zum Zurückschalten: Wert hier auf `'full_outline_headers'` setzen oder Datei reverten.
* Früher: Umschalter nur in dieser Datei. Jetzt: unter Einstellungen speichern;
* hier nur Konstanten und Resolver.
*/
export const PLANNING_MODULE_UX_MODE = 'compact_tag_legend'
export const PLANNING_MODULE_DISPLAY_MODES = /** @type {const} */ ({
COMPACT_TAG_LEGEND: 'compact_tag_legend',
FULL_OUTLINE_HEADERS: 'full_outline_headers',
})
/** @param {string | undefined | null} pref */
export function resolvePlanningModuleDisplayMode(pref) {
if (pref === PLANNING_MODULE_DISPLAY_MODES.FULL_OUTLINE_HEADERS) {
return PLANNING_MODULE_DISPLAY_MODES.FULL_OUTLINE_HEADERS
}
return PLANNING_MODULE_DISPLAY_MODES.COMPACT_TAG_LEGEND
}
/** @param {string | undefined | null} pref */
export function isCompactTagLegendMode(pref) {
return resolvePlanningModuleDisplayMode(pref) === PLANNING_MODULE_DISPLAY_MODES.COMPACT_TAG_LEGEND
}

View File

@ -1,6 +1,10 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import {
PLANNING_MODULE_DISPLAY_MODES,
resolvePlanningModuleDisplayMode,
} from '../config/planningModuleUx'
import api from '../utils/api'
/**
@ -17,6 +21,12 @@ function AccountSettingsPage() {
const [joinMessage, setJoinMessage] = useState('')
const [joinBusy, setJoinBusy] = useState(false)
const [planningPrefsBusy, setPlanningPrefsBusy] = useState(false)
/** @type {[string, React.Dispatch<React.SetStateAction<string>>]} */
const [moduleDisplayDraft, setModuleDisplayDraft] = useState(
PLANNING_MODULE_DISPLAY_MODES.COMPACT_TAG_LEGEND
)
const [newPw1, setNewPw1] = useState('')
const [newPw2, setNewPw2] = useState('')
const [savingPw, setSavingPw] = useState(false)
@ -29,6 +39,11 @@ function AccountSettingsPage() {
setName(typeof user?.name === 'string' ? user.name : '')
}, [user])
useEffect(() => {
const m = resolvePlanningModuleDisplayMode(user?.training_planning_prefs?.module_display_mode)
setModuleDisplayDraft(m)
}, [user?.id, user?.training_planning_prefs])
const refreshJoinRequests = () => {
api.getMyClubJoinRequests().then(setMyJoinRequests).catch(() => {})
}
@ -93,6 +108,26 @@ function AccountSettingsPage() {
}
}
const handleSavePlanningPrefs = async (e) => {
e.preventDefault()
if (!user?.id) return
setPlanningPrefsBusy(true)
try {
const base =
user.training_planning_prefs && typeof user.training_planning_prefs === 'object'
? { ...user.training_planning_prefs }
: {}
const merged = { ...base, module_display_mode: moduleDisplayDraft }
await api.updateProfile(user.id, { training_planning_prefs: merged })
await checkAuth()
showOk('Planungs-Anzeige gespeichert.')
} catch (err) {
showErr(err.message || 'Konnte nicht speichern.')
} finally {
setPlanningPrefsBusy(false)
}
}
const handleResendVerification = async () => {
const em = user?.email
if (!em) return
@ -276,6 +311,49 @@ function AccountSettingsPage() {
</div>
</div>
<div className="card" style={{ marginBottom: '1rem' }}>
<h2 style={{ margin: '0 0 0.75rem', fontSize: '1.1rem' }}>Trainingsplanung</h2>
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', marginBottom: '1rem', lineHeight: 1.5 }}>
Wie kopiert aus der Modul-Bibliothek übernommenen Blöcken in einer Einheit dargestellt werden.
</p>
<form onSubmit={handleSavePlanningPrefs}>
<fieldset style={{ margin: 0, padding: 0, border: 'none' }}>
<legend className="form-label" style={{ marginBottom: '0.5rem' }}>
Darstellung Aus Modul
</legend>
<label
style={{ display: 'flex', gap: '0.5rem', alignItems: 'flex-start', cursor: 'pointer', marginBottom: '0.65rem' }}
>
<input
type="radio"
name="planning-module-display"
checked={moduleDisplayDraft === PLANNING_MODULE_DISPLAY_MODES.COMPACT_TAG_LEGEND}
onChange={() => setModuleDisplayDraft(PLANNING_MODULE_DISPLAY_MODES.COMPACT_TAG_LEGEND)}
/>
<span style={{ lineHeight: 1.45, fontSize: '0.9rem', color: 'var(--text1)' }}>
<strong>Kompakt</strong> farbige Markierung je Modul, Tag pro Zeile, Legende im Abschnitt.
</span>
</label>
<label
style={{ display: 'flex', gap: '0.5rem', alignItems: 'flex-start', cursor: 'pointer', marginBottom: '0.75rem' }}
>
<input
type="radio"
name="planning-module-display"
checked={moduleDisplayDraft === PLANNING_MODULE_DISPLAY_MODES.FULL_OUTLINE_HEADERS}
onChange={() => setModuleDisplayDraft(PLANNING_MODULE_DISPLAY_MODES.FULL_OUTLINE_HEADERS)}
/>
<span style={{ lineHeight: 1.45, fontSize: '0.9rem', color: 'var(--text1)' }}>
<strong>Ausführlich</strong> großer Modul-Kopfbereich mit nummerierter Übungsliste.
</span>
</label>
</fieldset>
<button type="submit" className="btn btn-secondary" disabled={planningPrefsBusy}>
{planningPrefsBusy ? 'Speichern…' : 'Anzeige speichern'}
</button>
</form>
</div>
<div className="card" style={{ marginBottom: '1rem' }}>
<h2 style={{ margin: '0 0 0.75rem', fontSize: '1.1rem' }}>Vereinsbeitritt</h2>
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', marginBottom: '1rem', lineHeight: 1.5 }}>