From f4f5642c21315c642e90dd9ff253e1d614997196 Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 12 May 2026 22:36:19 +0200 Subject: [PATCH] feat(profiles): add training planning preferences to user profile - 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 --- .../055_profiles_training_planning_prefs.sql | 3 + backend/models.py | 4 + backend/routers/profiles.py | 9 +++ backend/version.py | 13 +++- .../components/TrainingUnitSectionsEditor.jsx | 26 ++++--- frontend/src/config/planningModuleUx.js | 27 +++++-- frontend/src/pages/AccountSettingsPage.jsx | 78 +++++++++++++++++++ 7 files changed, 140 insertions(+), 20 deletions(-) create mode 100644 backend/migrations/055_profiles_training_planning_prefs.sql diff --git a/backend/migrations/055_profiles_training_planning_prefs.sql b/backend/migrations/055_profiles_training_planning_prefs.sql new file mode 100644 index 0000000..b5db616 --- /dev/null +++ b/backend/migrations/055_profiles_training_planning_prefs.sql @@ -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; diff --git a/backend/models.py b/backend/models.py index ded7613..c2dc7b0 100644 --- a/backend/models.py +++ b/backend/models.py @@ -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 diff --git a/backend/routers/profiles.py b/backend/routers/profiles.py index 3785b84..c554b3e 100644 --- a/backend/routers/profiles.py +++ b/backend/routers/profiles.py @@ -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": diff --git a/backend/version.py b/backend/version.py index e23c351..03d94db 100644 --- a/backend/version.py +++ b/backend/version.py @@ -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", diff --git a/frontend/src/components/TrainingUnitSectionsEditor.jsx b/frontend/src/components/TrainingUnitSectionsEditor.jsx index 281d877..bd812c7 100644 --- a/frontend/src/components/TrainingUnitSectionsEditor.jsx +++ b/frontend/src/components/TrainingUnitSectionsEditor.jsx @@ -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 ( - {!PLANNING_USE_COMPACT_LEGEND && + {!planningCompactLegend && renderModulePlanningHead(modBandTitle, modOutline, showModuleBand)}
- {!isSepLine && PLANNING_USE_COMPACT_LEGEND && curMn ? ( + {!isSepLine && planningCompactLegend && curMn ? ( ) : null} @@ -843,7 +847,7 @@ export default function TrainingUnitSectionsEditor({ return ( - {!PLANNING_USE_COMPACT_LEGEND && + {!planningCompactLegend && renderModulePlanningHead(modBandTitle, modOutline, showModuleBand)}
Keine Übung gewählt )} - {PLANNING_USE_COMPACT_LEGEND && curMn ? ( + {planningCompactLegend && curMn ? ( ) : null} diff --git a/frontend/src/config/planningModuleUx.js b/frontend/src/config/planningModuleUx.js index 22dad26..c233fca 100644 --- a/frontend/src/config/planningModuleUx.js +++ b/frontend/src/config/planningModuleUx.js @@ -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 +} diff --git a/frontend/src/pages/AccountSettingsPage.jsx b/frontend/src/pages/AccountSettingsPage.jsx index 047e515..98381ab 100644 --- a/frontend/src/pages/AccountSettingsPage.jsx +++ b/frontend/src/pages/AccountSettingsPage.jsx @@ -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>]} */ + 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() {
+
+

Trainingsplanung

+

+ Wie kopiert aus der Modul-Bibliothek übernommenen Blöcken in einer Einheit dargestellt werden. +

+
+
+ + Darstellung „Aus Modul“ + + + +
+ +
+
+

Vereinsbeitritt