feat(training-units): add compact legend and module styling in Training Unit Sections Editor
All checks were successful
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / playwright-tests (push) Successful in 56s

- Introduced new CSS styles for compact module display, enhancing the visual structure of training unit sections.
- Implemented functionality to conditionally render module tags and borders based on the selected UX mode.
- Enhanced the section module legend model to aggregate and display module information effectively.
- Improved the rendering logic to support both compact and standard views, ensuring a flexible user experience.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-05-12 22:30:55 +02:00
parent e96951728d
commit 05042ee9ec
3 changed files with 235 additions and 5 deletions

View File

@ -5402,6 +5402,103 @@ a.analysis-split__nav-item {
inset 10px 0 0 color-mix(in srgb, var(--accent) 14%, transparent);
}
/* Kompakt: Modulfarbe über CSS-Variable --tu-mod-border (pro Zeile gesetzt). */
.training-unit-sections-editor .tu-item-row--from-module-soft.tu-item-row--exercise,
.training-unit-sections-editor .tu-item-row--from-module-soft.tu-item-row--note:not(.tu-item-row--separator-note) {
border-left: 4px solid var(--tu-mod-border, var(--accent));
padding-left: 0.62rem;
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.52) inset,
0 2px 12px rgba(15, 23, 42, 0.05);
}
.tu-planning-mod-tag {
display: inline-flex;
align-items: center;
gap: 5px;
flex: 0 1 auto;
max-width: 100%;
margin: 0;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--border2) 70%, transparent);
font-size: 0.72rem;
font-weight: 700;
line-height: 1.35;
color: var(--text1);
}
.tu-planning-mod-tag__dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.tu-planning-mod-tag__text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tu-section-mod-legend {
margin-top: 0.75rem;
padding: 0.5rem 0.62rem;
border-radius: 11px;
border: 1px solid color-mix(in srgb, var(--border2) 80%, transparent);
background: color-mix(in srgb, var(--surface) 92%, transparent);
}
.tu-section-mod-legend__caption {
font-size: 0.65rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text3);
margin-bottom: 0.42rem;
}
.tu-section-mod-legend__list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-wrap: wrap;
gap: 0.42rem 0.72rem;
}
.tu-section-mod-legend__item {
display: inline-flex;
align-items: flex-start;
gap: 8px;
flex: 1 1 200px;
min-width: 0;
}
.tu-section-mod-legend__swatch {
width: 11px;
height: 11px;
border-radius: 3px;
flex-shrink: 0;
margin-top: 0.26rem;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.06);
}
.tu-section-mod-legend__title {
display: block;
font-size: 0.82rem;
font-weight: 700;
line-height: 1.35;
color: var(--text1);
}
.tu-section-mod-legend__meta {
display: block;
font-size: 0.72rem;
line-height: 1.42;
color: var(--text3);
}
.tu-item-row {
display: flex;
flex-wrap: wrap;

View File

@ -6,6 +6,7 @@ import {
noteRow,
sectionPlannedMinutes,
} from '../utils/trainingUnitSectionsForm'
import { PLANNING_MODULE_UX_MODE } from '../config/planningModuleUx'
const DND_TU_ITEM = 'application/x-shinkan-training-unit-item'
const DND_TU_SECTION = 'application/x-shinkan-training-section-v1'
@ -55,6 +56,61 @@ 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)
const n = id != null && id >= 1 ? Math.floor(Number(id)) : 1
const golden = ((n >>> 0) * 2654435761 + n * 73856093) >>> 0
const h = golden % 360
const border = `hsl(${h} 52% 36%)`
const soft = `hsl(${h} 42% 94%)`
return { border, soft, hue: h }
}
function PlanningModuleRowTag({ moduleId, title }) {
const p = planningModulePalette(moduleId)
const lbl = truncatePreview(title || `Modul #${moduleId}`, 34).trim()
const fullTitle = ((title || '').trim() || `Modul #${moduleId}`).trim()
return (
<span
className="tu-planning-mod-tag"
style={{ borderColor: p.border, backgroundColor: p.soft }}
title={`${fullTitle} (Bibliotheks-ID ${moduleId})`}
>
<span className="tu-planning-mod-tag__dot" style={{ background: p.border }} aria-hidden />
<span className="tu-planning-mod-tag__text">Aus Modul: {lbl}</span>
</span>
)
}
/** Eindeutige Module im Abschnitt mit Zählerständen für die Legende. */
function sectionModuleLegendModel(items) {
const map = new Map()
for (const row of items || []) {
const id = normalizedPlanningModuleChainId(row.source_training_module_id)
if (id == null) continue
if (!map.has(id)) {
map.set(id, {
id,
title: (((row.source_module_title || '').trim() || '') || `Modul #${id}`).trim(),
exercises: 0,
notes: 0,
})
}
const agg = map.get(id)
if ((row.item_type || '') === 'note') {
const bod = ((row.note_body || '').trim() || '').trim()
if (bod === SECTION_INSERT_SEPARATOR_BODY) continue
agg.notes += 1
} else {
agg.exercises += 1
}
}
return [...map.values()].sort((a, b) => a.id - b.id)
}
function renderModulePlanningHead(modBandTitle, modOutline, showModuleBand) {
if (!showModuleBand || !modOutline) return null
return (
@ -515,6 +571,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 bandActiveBefore = (bx) =>
enableSectionDragReorder &&
dropSectionBand &&
@ -656,8 +713,21 @@ export default function TrainingUnitSectionsEditor({
(curMn != null ? `Modul #${curMn}` : '')
const modOutline =
showModuleBand && curMn != null ? gatherPlanningModuleOutline(sec.items, iIdx, curMn) : null
const fromModClass = curMn != null ? ' tu-item-row--from-module' : ''
!PLANNING_USE_COMPACT_LEGEND &&
showModuleBand &&
curMn != null
? gatherPlanningModuleOutline(sec.items, iIdx, curMn)
: null
const fromModClass =
curMn != null
? PLANNING_USE_COMPACT_LEGEND
? ' tu-item-row--from-module-soft'
: ' tu-item-row--from-module'
: ''
const modBorderVarStyle =
PLANNING_USE_COMPACT_LEGEND && curMn != null
? { '--tu-mod-border': planningModulePalette(curMn).border }
: undefined
if (it.item_type === 'note') {
const isSepLine = (it.note_body || '').trim() === SECTION_INSERT_SEPARATOR_BODY
@ -665,7 +735,8 @@ export default function TrainingUnitSectionsEditor({
const noteHasText = Boolean((it.note_body || '').trim()) && !isSepLine
return (
<Fragment key={`${insertSlotKeyPrefix}sec-${sIdx}-blk-${iIdx}`}>
{renderModulePlanningHead(modBandTitle, modOutline, showModuleBand)}
{!PLANNING_USE_COMPACT_LEGEND &&
renderModulePlanningHead(modBandTitle, modOutline, showModuleBand)}
<div
className={
`${rowCommon} tu-item-row--note` +
@ -673,6 +744,7 @@ export default function TrainingUnitSectionsEditor({
fromModClass
}
{...dndRowProps}
style={modBorderVarStyle}
>
{enableItemDragReorder ? (
<span
@ -706,6 +778,9 @@ export default function TrainingUnitSectionsEditor({
</button>
</div>
<div className="tu-item-row__body tu-item-row__body--note">
{!isSepLine && PLANNING_USE_COMPACT_LEGEND && curMn ? (
<PlanningModuleRowTag moduleId={curMn} title={modBandTitle} />
) : null}
<span className="tu-item-row__meta-label">
{isSepLine ? 'Trennung' : 'Zwischen-Anmerkung'}
</span>
@ -768,8 +843,13 @@ export default function TrainingUnitSectionsEditor({
return (
<Fragment key={`${insertSlotKeyPrefix}sec-${sIdx}-blk-${iIdx}`}>
{renderModulePlanningHead(modBandTitle, modOutline, showModuleBand)}
<div className={`${rowCommon} tu-item-row--exercise${fromModClass}`} {...dndRowProps}>
{!PLANNING_USE_COMPACT_LEGEND &&
renderModulePlanningHead(modBandTitle, modOutline, showModuleBand)}
<div
className={`${rowCommon} tu-item-row--exercise${fromModClass}`}
{...dndRowProps}
style={modBorderVarStyle}
>
<div className="tu-item-row__mainline">
{enableItemDragReorder ? (
<span
@ -809,6 +889,9 @@ export default function TrainingUnitSectionsEditor({
) : (
<span className="tu-ex-title-placeholder">Keine Übung gewählt</span>
)}
{PLANNING_USE_COMPACT_LEGEND && curMn ? (
<PlanningModuleRowTag moduleId={curMn} title={modBandTitle} />
) : null}
<span className="tu-ex-inline-actions">
<button
type="button"
@ -990,6 +1073,45 @@ export default function TrainingUnitSectionsEditor({
</div>
)}
</div>
{moduleLegend.length ? (
<div
className="tu-section-mod-legend"
aria-label="Liste der eingebundenen Trainingsmodule in diesem Abschnitt"
>
<div className="tu-section-mod-legend__caption">Übernommene Module im Abschnitt</div>
<ul className="tu-section-mod-legend__list">
{moduleLegend.map((e) => {
const pal = planningModulePalette(e.id)
return (
<li key={`mod-leg-${sIdx}-${e.id}`} className="tu-section-mod-legend__item">
<span
className="tu-section-mod-legend__swatch"
style={{ background: pal.border }}
title={`Farbe wie an den Zeilen (Modul #${e.id})`}
aria-hidden
/>
<span className="tu-section-mod-legend__text">
<span className="tu-section-mod-legend__title">
{(e.title || '').trim() || `Modul #${e.id}`}
</span>
<span className="tu-section-mod-legend__meta">
ID {e.id} · {e.exercises}{' '}
{e.exercises === 1 ? 'Übung' : 'Übungen'}
{e.notes > 0 ? (
<>
{' '}
· {e.notes} {e.notes === 1 ? 'Zwischen-Hinweis' : 'Zwischen-Hinweise'}
</>
) : null}
</span>
</span>
</li>
)
})}
</ul>
</div>
) : null}
</div>
</Fragment>
)

View File

@ -0,0 +1,11 @@
/**
* 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).
*
* Zum Zurückschalten: Wert hier auf `'full_outline_headers'` setzen oder Datei reverten.
*/
export const PLANNING_MODULE_UX_MODE = 'compact_tag_legend'