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
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:
parent
05042ee9ec
commit
f4f5642c21
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }}>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user