feat: enhance dashboard and exercises list UI with new styles and filtering options
All checks were successful
Deploy Development / deploy (push) Successful in 37s
Test Suite / pytest-backend (push) Successful in 7s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Successful in 23s

- Added responsive styles for dashboard KPI cards and exercise search actions, improving layout on smaller screens.
- Refactored the Dashboard component to streamline error handling and loading states for better user experience.
- Updated ExercisesListPage to include new filtering options for user-created exercises, enhancing data relevance.
- Improved overall CSS organization and introduced new classes for better styling consistency across components.
This commit is contained in:
Lars 2026-05-07 08:47:01 +02:00
parent 3bf0af001a
commit 40a3b4b8e6
3 changed files with 163 additions and 67 deletions

View File

@ -2706,6 +2706,21 @@ a.analysis-split__nav-item {
gap: 10px;
margin-top: 12px;
}
.exercise-search-bar__actions--split {
width: 100%;
}
.exercise-search-bar__actions-main {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.exercise-mine-toggle--active {
border-color: var(--accent);
background: var(--accent-light);
color: var(--accent-dark);
font-weight: 600;
}
.exercise-filter-trigger {
display: inline-flex;
align-items: center;
@ -3607,13 +3622,11 @@ a.analysis-split__nav-item {
margin: 0 0 10px;
font-size: 0.9rem;
color: var(--danger);
grid-column: 1 / -1;
}
.dashboard-phase0-kpis__loading {
font-size: 0.9rem;
margin: 0;
grid-column: 1 / -1;
margin: 0 0 10px;
}
.dashboard-kpi-card {
@ -3685,6 +3698,55 @@ a.analysis-split__nav-item {
margin-top: auto;
}
@media (max-width: 1023px) {
.dashboard-phase0-kpis {
display: flex;
flex-wrap: nowrap;
gap: 8px;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
scroll-snap-type: x proximity;
scrollbar-width: none;
padding: 2px 0 4px;
margin-left: calc(-1 * max(12px, env(safe-area-inset-left, 0px)));
margin-right: calc(-1 * max(12px, env(safe-area-inset-right, 0px)));
padding-left: max(12px, env(safe-area-inset-left, 0px));
padding-right: max(12px, env(safe-area-inset-right, 0px));
}
.dashboard-phase0-kpis::-webkit-scrollbar {
display: none;
}
.dashboard-kpi-card {
flex: 0 0 auto;
scroll-snap-align: start;
width: min(132px, 38vw);
min-height: 0;
padding: 10px 10px 8px;
gap: 2px;
}
.dashboard-kpi-card__icon {
width: 32px;
height: 32px;
margin-bottom: 0;
}
.dashboard-kpi-card__icon svg {
width: 18px;
height: 18px;
}
.dashboard-kpi-card__value {
font-size: 1.35rem;
}
.dashboard-kpi-card__label {
font-size: 0.72rem;
line-height: 1.25;
}
.dashboard-kpi-card__hint {
font-size: 0.6rem;
letter-spacing: 0.04em;
}
}
.dashboard-training-preview-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr));

View File

@ -191,49 +191,48 @@ function Dashboard() {
</p>
</div>
</div>
<div className="dashboard-phase0-kpis">
{phase0Err ? (
<p className="dashboard-phase0-kpis__err" role="alert">
{phase0Err}
</p>
) : null}
{!phase0Err && phase0Stats ? (
<>
<Link className="dashboard-kpi-card" to={draftsHref}>
<span className="dashboard-kpi-card__icon" aria-hidden>
<FilePenLine size={22} strokeWidth={2} />
</span>
<span className="dashboard-kpi-card__value">
{formatCappedCount(phase0Stats.draftCount, phase0Stats.draftCapped)}
</span>
<span className="dashboard-kpi-card__label">Übungs-Entwürfe</span>
<span className="dashboard-kpi-card__hint">finalisieren</span>
</Link>
<Link className="dashboard-kpi-card" to={mineHref}>
<span className="dashboard-kpi-card__icon" aria-hidden>
<Library size={22} strokeWidth={2} />
</span>
<span className="dashboard-kpi-card__value">
{formatCappedCount(phase0Stats.mineCount, phase0Stats.mineCapped)}
</span>
<span className="dashboard-kpi-card__label">Meine Übungen</span>
<span className="dashboard-kpi-card__hint">alle Status</span>
</Link>
<div className="dashboard-kpi-card dashboard-kpi-card--static">
<span className="dashboard-kpi-card__icon" aria-hidden>
<ClipboardList size={22} strokeWidth={2} />
</span>
<span className="dashboard-kpi-card__value">
{formatCappedCount(phase0Stats.ytdCompletedCount, phase0Stats.ytdCapped)}
</span>
<span className="dashboard-kpi-card__label">Gehalten {phase0Stats.year}</span>
<span className="dashboard-kpi-card__hint">abrechnungsnah</span>
</div>
</>
) : !phase0Err ? (
<div className="dashboard-phase0-kpis__loading muted">Zahlen werden geladen</div>
) : null}
</div>
{phase0Err ? (
<p className="dashboard-phase0-kpis__err" role="alert">
{phase0Err}
</p>
) : null}
{!phase0Err && !phase0Stats ? (
<div className="dashboard-phase0-kpis__loading muted">Zahlen werden geladen</div>
) : null}
{!phase0Err && phase0Stats ? (
<div className="dashboard-phase0-kpis">
<Link className="dashboard-kpi-card" to={draftsHref}>
<span className="dashboard-kpi-card__icon" aria-hidden>
<FilePenLine size={22} strokeWidth={2} />
</span>
<span className="dashboard-kpi-card__value">
{formatCappedCount(phase0Stats.draftCount, phase0Stats.draftCapped)}
</span>
<span className="dashboard-kpi-card__label">Übungs-Entwürfe</span>
<span className="dashboard-kpi-card__hint">finalisieren</span>
</Link>
<Link className="dashboard-kpi-card" to={mineHref}>
<span className="dashboard-kpi-card__icon" aria-hidden>
<Library size={22} strokeWidth={2} />
</span>
<span className="dashboard-kpi-card__value">
{formatCappedCount(phase0Stats.mineCount, phase0Stats.mineCapped)}
</span>
<span className="dashboard-kpi-card__label">Meine Übungen</span>
<span className="dashboard-kpi-card__hint">alle Status</span>
</Link>
<div className="dashboard-kpi-card dashboard-kpi-card--static">
<span className="dashboard-kpi-card__icon" aria-hidden>
<ClipboardList size={22} strokeWidth={2} />
</span>
<span className="dashboard-kpi-card__value">
{formatCappedCount(phase0Stats.ytdCompletedCount, phase0Stats.ytdCapped)}
</span>
<span className="dashboard-kpi-card__label">Gehalten {phase0Stats.year}</span>
<span className="dashboard-kpi-card__hint">abrechnungsnah</span>
</div>
</div>
) : null}
</section>
<section className="dashboard-section" aria-labelledby="dash-trainings-title">

View File

@ -123,16 +123,29 @@ function levelOptionShort(levelStr) {
return o ? String(o.level) : String(levelStr)
}
function exerciseListFiltersWithDashboardUrl(mergedFilters) {
function applyDashboardExerciseListUrl(mergedFromPrefs) {
try {
const sp = new URLSearchParams(window.location.search)
if (sp.get('status') !== 'draft') return mergedFilters
return {
...mergedFilters,
status_rules: [{ key: 'url-dashboard-draft', id: 'draft', mode: 'require' }],
const mine = sp.get('mine') === '1' || sp.get('created_by_me') === '1'
const statusDraft = sp.get('status') === 'draft'
if (mine) {
const next = { ...INITIAL_EXERCISE_LIST_FILTERS }
if (statusDraft) {
next.status_rules = [{ key: 'url-dashboard-draft', id: 'draft', mode: 'require' }]
}
return next
}
if (statusDraft) {
return {
...mergedFromPrefs,
status_rules: [{ key: 'url-dashboard-draft', id: 'draft', mode: 'require' }],
}
}
return mergedFromPrefs
} catch {
return mergedFilters
return mergedFromPrefs
}
}
@ -192,7 +205,7 @@ function ExercisesListPage() {
if (!user?.id) return
if (prefsAppliedRef.current) return
const merged = mergeExerciseListPrefsFromApi(user.exercise_list_prefs)
setFilters(exerciseListFiltersWithDashboardUrl(merged))
setFilters(applyDashboardExerciseListUrl(merged))
try {
const sp = new URLSearchParams(window.location.search)
if (sp.get('mine') === '1' || sp.get('created_by_me') === '1') setMineOnly(true)
@ -270,6 +283,14 @@ function ExercisesListPage() {
const filterChips = useMemo(() => {
const chips = []
if (mineOnly) {
chips.push({
key: 'mine-only',
label: 'Nur von mir erstellt',
onRemove: () => setMineOnly(false),
})
}
pushCatalogRuleFilterChips(chips, 'focus_rules', filters.focus_rules, focusOptions, 'Fokus', setFilters)
if (filters.focus_only_without) {
@ -410,6 +431,7 @@ function ExercisesListPage() {
return chips
}, [
mineOnly,
filters,
focusOptions,
styleOptions,
@ -625,7 +647,10 @@ function ExercisesListPage() {
}
}
const resetAllFilters = useCallback(() => setFilters({ ...INITIAL_EXERCISE_LIST_FILTERS }), [])
const resetAllFilters = useCallback(() => {
setMineOnly(false)
setFilters({ ...INITIAL_EXERCISE_LIST_FILTERS })
}, [])
const handleSaveExerciseFilterPrefs = useCallback(async () => {
const uid = user?.id
@ -833,20 +858,30 @@ function ExercisesListPage() {
list="exercise-search-titles"
enterKeyHint="search"
/>
<div className="exercise-search-bar__actions">
<button type="button" className="btn btn-secondary exercise-filter-trigger" onClick={() => setFilterModalOpen(true)}>
Filter
{filterChips.length > 0 ? (
<span className="exercise-filter-badge" aria-hidden>
{filterChips.length}
</span>
) : null}
</button>
{filterChips.length > 0 ? (
<button type="button" className="btn" onClick={resetAllFilters}>
Alle entfernen
<div className="exercise-search-bar__actions exercise-search-bar__actions--split">
<div className="exercise-search-bar__actions-main">
<button
type="button"
className={'btn btn-secondary exercise-mine-toggle' + (mineOnly ? ' exercise-mine-toggle--active' : '')}
onClick={() => setMineOnly((v) => !v)}
title="Nur Übungen, die mit deinem Profil als Ersteller gespeichert sind"
>
Meine Übungen
</button>
) : null}
<button type="button" className="btn btn-secondary exercise-filter-trigger" onClick={() => setFilterModalOpen(true)}>
Filter
{filterChips.length > 0 ? (
<span className="exercise-filter-badge" aria-hidden>
{filterChips.length}
</span>
) : null}
</button>
{filterChips.length > 0 ? (
<button type="button" className="btn" onClick={resetAllFilters}>
Alle entfernen
</button>
) : null}
</div>
</div>
{filterChips.length > 0 ? (
<div className="exercise-filter-chips-row" role="list" aria-label="Aktive Filter">