Enhance TrainingCoachPage and TrainingUnitRunPage with improved context display and print functionality
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m8s
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m8s
- Updated TrainingCoachPage to include coach context in timeline entries and section titles, enhancing clarity for users. - Refactored TrainingUnitRunPage to support printing options for parallel streams, allowing for better organization during printouts. - Introduced new CSS styles for page breaks and layout adjustments in app.css, improving print formatting for training plans. - Enhanced utility functions in trainingPlanUtils.js to support new phase and stream management features, streamlining data handling.
This commit is contained in:
parent
3005f1cb3e
commit
5338871f36
|
|
@ -6658,6 +6658,21 @@ button.combo-coach-cand-link:hover {
|
|||
max-width: none !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
/* Split: jede weitere Breakout-Spalte auf neuer Seite (Gesamtplan-Druck) */
|
||||
.training-run-breakout-stream--page-break {
|
||||
break-before: page;
|
||||
page-break-before: always;
|
||||
}
|
||||
.training-run-parallel-columns {
|
||||
display: block !important;
|
||||
}
|
||||
.training-run-breakout-stream {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.training-run-phase-schedule {
|
||||
break-inside: avoid;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
.training-run-section,
|
||||
.training-run-header {
|
||||
break-inside: avoid;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/**
|
||||
* Coach-Modus: eine Position nach der anderen mit Assistentenhinweisen, Zeitnahme und optionaler Nachbereitung.
|
||||
* Parallele Streams: Schritte aus flattenPlanTimeline (linear); Stream-/Phasenwahl später einzubinden.
|
||||
* Timeline: flach in Phasen-/Stream-Reihenfolge (flattenPlanTimeline).
|
||||
*/
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
|
|
@ -525,7 +525,7 @@ export default function TrainingCoachPage() {
|
|||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', overflowY: 'auto', minHeight: 0 }}>
|
||||
{timeline.map((ent, ix) => {
|
||||
const lbl = summarizeTimelineEntry(ent)
|
||||
const secTitle = ent.sec.title || `Abschnitt ${ent.si + 1}`
|
||||
const ctx = ent.coachContext || ''
|
||||
const active = coachDebriefPhase ? ix === timeline.length - 1 : ix === step
|
||||
return (
|
||||
<button
|
||||
|
|
@ -543,7 +543,11 @@ export default function TrainingCoachPage() {
|
|||
setStep(ix)
|
||||
}}
|
||||
>
|
||||
<span style={{ opacity: 0.75 }}>{secTitle.substring(0, 14)} › </span>
|
||||
{ctx ? (
|
||||
<span style={{ opacity: 0.8, display: 'block', fontSize: '0.72rem', marginBottom: '2px' }}>
|
||||
{ctx.length > 42 ? `${ctx.slice(0, 40)}…` : ctx}
|
||||
</span>
|
||||
) : null}
|
||||
<span>{lbl}</span>
|
||||
</button>
|
||||
)
|
||||
|
|
@ -573,7 +577,10 @@ export default function TrainingCoachPage() {
|
|||
const val = deltas[k]?.actual_duration_min ?? ent.item.actual_duration_min ?? ''
|
||||
return (
|
||||
<label key={`db-${k}`} style={{ display: 'grid', gridTemplateColumns: '1fr 88px', gap: '10px', alignItems: 'center', fontSize: '0.88rem' }}>
|
||||
<span style={{ wordBreak: 'break-word' }}>{summarizeTimelineEntry(ent)}</span>
|
||||
<span style={{ wordBreak: 'break-word' }}>
|
||||
{ent.coachContext ? <span style={{ color: 'var(--text3)' }}>{ent.coachContext} · </span> : null}
|
||||
{summarizeTimelineEntry(ent)}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
|
|
@ -692,7 +699,8 @@ export default function TrainingCoachPage() {
|
|||
{currentEntry?.item?.item_type === 'note' ? (
|
||||
<div className="card" style={{ padding: '16px 14px' }}>
|
||||
<div style={{ fontSize: '0.74rem', color: 'var(--text3)', marginBottom: '8px' }}>
|
||||
{currentEntry.sec.title || 'Abschnitt'} · Coach-Notiz · Teil {step + 1}
|
||||
{currentEntry.coachContext || currentEntry.sec.title || 'Abschnitt'} · Coach-Notiz · Teil{' '}
|
||||
{step + 1}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.78rem', color: 'var(--text3)', marginBottom: '6px' }}>Coach-Notiz</div>
|
||||
<p style={{ fontSize: '1.05rem', lineHeight: 1.52, whiteSpace: 'pre-wrap', margin: 0 }}>{currentEntry.item.note_body || ''}</p>
|
||||
|
|
@ -701,7 +709,8 @@ export default function TrainingCoachPage() {
|
|||
<>
|
||||
<div className="card training-coach-plan-strip" style={{ padding: '12px 14px', marginBottom: '12px', borderRadius: '12px', borderLeft: `3px solid var(--accent)` }}>
|
||||
<div style={{ fontSize: '0.74rem', color: 'var(--text3)', marginBottom: '6px' }}>
|
||||
In diesem Training · {currentEntry?.sec.title || 'Abschnitt'} · Teil {step + 1}
|
||||
In diesem Training · {currentEntry.coachContext || currentEntry?.sec.title || 'Abschnitt'} · Teil{' '}
|
||||
{step + 1}
|
||||
</div>
|
||||
{currentEntry?.item && (
|
||||
<>
|
||||
|
|
@ -795,7 +804,10 @@ export default function TrainingCoachPage() {
|
|||
const val = deltas[k]?.actual_duration_min ?? ent.item.actual_duration_min ?? ''
|
||||
return (
|
||||
<label key={k} style={{ display: 'grid', gridTemplateColumns: '1fr 88px', gap: '10px', alignItems: 'center', fontSize: '0.88rem' }}>
|
||||
<span style={{ wordBreak: 'break-word' }}>{summarizeTimelineEntry(ent)}</span>
|
||||
<span style={{ wordBreak: 'break-word' }}>
|
||||
{ent.coachContext ? <span style={{ color: 'var(--text3)' }}>{ent.coachContext} · </span> : null}
|
||||
{summarizeTimelineEntry(ent)}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
|
|
|
|||
|
|
@ -1,13 +1,18 @@
|
|||
/**
|
||||
* Trainingsablauf anzeigen, drucken und lokal auf der Matte abhaken (Fortschritt im Browser gespeichert).
|
||||
* Parallele Streams: rendering nutzt sortedSections/sortedItems (trainingPlanUtils); Fortschritt pro unitId — später ggf. pro Stream.
|
||||
* Phasen: Ganzgruppe vs. Split (planLoc); Druck mit optional getrennten Breakout-Seiten.
|
||||
*/
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import api from '../utils/api'
|
||||
import ExercisePeekModal from '../components/ExercisePeekModal'
|
||||
import CombinationPlanBracket from '../components/CombinationPlanBracket'
|
||||
import { itemStableKey, sortedSections, sortedItems } from '../utils/trainingPlanUtils'
|
||||
import {
|
||||
buildPlanRunViewModel,
|
||||
itemStableKey,
|
||||
sortedSections,
|
||||
sortedItems,
|
||||
} from '../utils/trainingPlanUtils'
|
||||
import { effectiveComboMethodProfile } from '../utils/comboPlanningMethodProfile'
|
||||
|
||||
function storageKey(unitId) {
|
||||
|
|
@ -37,6 +42,8 @@ export default function TrainingUnitRunPage() {
|
|||
const [loading, setLoading] = useState(true)
|
||||
const [checked, setChecked] = useState(() => new Set())
|
||||
const [peekCtx, setPeekCtx] = useState(null)
|
||||
/** null | printStreamId z. B. p0-s1 — nur dieser Split-Stream in @media print */
|
||||
const [printOnlyStreamId, setPrintOnlyStreamId] = useState(null)
|
||||
|
||||
const loadChecked = useCallback((uid) => {
|
||||
try {
|
||||
|
|
@ -109,19 +116,244 @@ export default function TrainingUnitRunPage() {
|
|||
}, [idNum, persistChecked])
|
||||
|
||||
const sections = useMemo(() => sortedSections(unit), [unit])
|
||||
const planModel = useMemo(() => buildPlanRunViewModel(unit), [unit])
|
||||
|
||||
const totalPlannedMin = useMemo(() => {
|
||||
let t = 0
|
||||
for (const sec of sections) {
|
||||
for (const it of sortedItems(sec)) {
|
||||
if (it.item_type === 'exercise' && it.planned_duration_min != null) {
|
||||
const n = Number(it.planned_duration_min)
|
||||
if (Number.isFinite(n)) t += n
|
||||
}
|
||||
const printStreamOptions = useMemo(() => {
|
||||
const opts = []
|
||||
for (const run of planModel.runs) {
|
||||
if (run.kind !== 'parallel' || !run.streams) continue
|
||||
for (const st of run.streams) {
|
||||
opts.push({
|
||||
id: st.printStreamId,
|
||||
label: `${run.phaseTitle ? String(run.phaseTitle).trim().slice(0, 28) : `Phase ${run.phaseOrderIndex}`} · ${st.streamTitle ? String(st.streamTitle).trim() : `Gruppe ${st.streamOrder + 1}`}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
return t
|
||||
}, [sections])
|
||||
return opts
|
||||
}, [planModel.runs])
|
||||
|
||||
const totalPlannedMin = planModel.totalMin
|
||||
const showWholeGroupInView = !printOnlyStreamId
|
||||
const showStreamColumn = (streamPrintId) => !printOnlyStreamId || streamPrintId === printOnlyStreamId
|
||||
|
||||
const renderSectionCard = (sec, siInUnit) => {
|
||||
const secOrder = sec.order_index ?? siInUnit
|
||||
const items = sortedItems(sec)
|
||||
return (
|
||||
<section
|
||||
key={sec.id ?? `sec-${secOrder}-${siInUnit}`}
|
||||
className="card training-run-section"
|
||||
style={{ padding: '1.15rem 1.25rem' }}
|
||||
>
|
||||
<h2 style={{ fontSize: '1.05rem', marginBottom: '0.65rem', color: 'var(--accent-dark)' }}>
|
||||
{sec.title || `Abschnitt ${siInUnit + 1}`}
|
||||
</h2>
|
||||
{sec.guidance_notes && (
|
||||
<p
|
||||
style={{
|
||||
fontSize: '0.88rem',
|
||||
color: 'var(--text2)',
|
||||
marginBottom: '0.85rem',
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{sec.guidance_notes}
|
||||
</p>
|
||||
)}
|
||||
<ul
|
||||
style={{
|
||||
listStyle: 'none',
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.65rem',
|
||||
}}
|
||||
>
|
||||
{items.map((it, ii) => {
|
||||
const ck = itemStableKey(it, secOrder, ii)
|
||||
const done = checked.has(ck)
|
||||
|
||||
if (it.item_type === 'note') {
|
||||
return (
|
||||
<li
|
||||
key={ck}
|
||||
className={`training-run-item training-run-item--note${done ? ' training-run-item--done' : ''}`}
|
||||
>
|
||||
<label
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '0.75rem',
|
||||
alignItems: 'flex-start',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.92rem',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="training-run-checkbox training-run-checkbox--printable"
|
||||
checked={done}
|
||||
onChange={() => toggle(ck)}
|
||||
style={{ marginTop: '4px', width: '20px', height: '20px' }}
|
||||
/>
|
||||
<span style={{ whiteSpace: 'pre-wrap', color: 'var(--text2)' }}>
|
||||
<em style={{ fontStyle: 'normal', color: 'var(--text3)', fontSize: '0.8rem' }}>
|
||||
Notiz
|
||||
</em>
|
||||
<br />
|
||||
{it.note_body || ''}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
const title =
|
||||
it.exercise_title || (it.exercise_id ? `Übung #${it.exercise_id}` : 'Übung')
|
||||
const variant = it.exercise_variant_name ? ` (${it.exercise_variant_name})` : ''
|
||||
const plan = formatMin(it.planned_duration_min)
|
||||
const extras = []
|
||||
if (it.exercise_focus_area) extras.push(it.exercise_focus_area)
|
||||
const exKind = String(it.exercise_kind || 'simple').toLowerCase().trim()
|
||||
const isComboRow = exKind === 'combination'
|
||||
const metaParts = [...extras, isComboRow ? 'Kombination' : null, plan].filter(Boolean)
|
||||
const comboEffectiveProfile = isComboRow
|
||||
? effectiveComboMethodProfile(
|
||||
it.catalog_method_profile || {},
|
||||
it.planning_method_profile ?? null
|
||||
)
|
||||
: null
|
||||
|
||||
return (
|
||||
<li
|
||||
key={ck}
|
||||
className={`training-run-item training-run-item--exercise${done ? ' training-run-item--done' : ''}`}
|
||||
>
|
||||
<label
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '0.75rem',
|
||||
alignItems: 'flex-start',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="training-run-checkbox training-run-checkbox--printable"
|
||||
checked={done}
|
||||
onChange={() => toggle(ck)}
|
||||
style={{
|
||||
marginTop: '6px',
|
||||
width: '22px',
|
||||
height: '22px',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<span style={{ flex: 1, minWidth: 0 }}>
|
||||
<span style={{ fontSize: '1.02rem', fontWeight: 600 }}>
|
||||
{title}
|
||||
{variant}
|
||||
</span>
|
||||
{metaParts.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.85rem',
|
||||
color: 'var(--text3)',
|
||||
marginTop: '3px',
|
||||
lineHeight: 1.35,
|
||||
}}
|
||||
>
|
||||
{metaParts.join(' · ')}
|
||||
</div>
|
||||
)}
|
||||
{(it.notes || it.modifications) && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: '0.35rem',
|
||||
fontSize: '0.88rem',
|
||||
color: 'var(--text2)',
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{it.notes && (
|
||||
<>
|
||||
<strong style={{ fontWeight: 600 }}>Coach:</strong> {it.notes}
|
||||
</>
|
||||
)}
|
||||
{it.modifications && (
|
||||
<>
|
||||
{it.notes ? <br /> : null}
|
||||
<strong style={{ fontWeight: 600 }}>Anpassung:</strong> {it.modifications}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isComboRow && it.exercise_id ? (
|
||||
<div className="training-run-combo-embed">
|
||||
<CombinationPlanBracket
|
||||
methodArchetype={String(it.catalog_method_archetype || '').trim()}
|
||||
methodProfile={comboEffectiveProfile || {}}
|
||||
combinationSlots={
|
||||
Array.isArray(it.combination_slots) ? it.combination_slots : []
|
||||
}
|
||||
planningAdjusted={
|
||||
it.planning_method_profile != null &&
|
||||
typeof it.planning_method_profile === 'object' &&
|
||||
!Array.isArray(it.planning_method_profile)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{it.exercise_id && (
|
||||
<div
|
||||
className="no-print"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '10px',
|
||||
marginTop: '0.55rem',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: '0.82rem', margin: 0 }}
|
||||
onClick={() =>
|
||||
setPeekCtx({
|
||||
exerciseId: it.exercise_id,
|
||||
variantId:
|
||||
it.exercise_variant_id != null
|
||||
? Number(it.exercise_variant_id)
|
||||
: null,
|
||||
peekExtras: isComboRow
|
||||
? {
|
||||
catalog_method_profile: it.catalog_method_profile,
|
||||
planning_method_profile: it.planning_method_profile,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
Katalog (Popup)
|
||||
</button>
|
||||
<Link
|
||||
to={`/exercises/${it.exercise_id}`}
|
||||
style={{ fontSize: '0.82rem', color: 'var(--accent)' }}
|
||||
>
|
||||
Vollständige Seite öffnen
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
|
@ -153,21 +385,66 @@ export default function TrainingUnitRunPage() {
|
|||
onClose={() => setPeekCtx(null)}
|
||||
/>
|
||||
|
||||
<nav className="training-run-toolbar no-print" style={{ marginBottom: '1rem', display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<nav
|
||||
className="training-run-toolbar no-print"
|
||||
style={{
|
||||
marginBottom: '1rem',
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => navigate('/planning')}>
|
||||
Zur Planung
|
||||
</button>
|
||||
<Link
|
||||
to={`/planning/run/${unitId}/coach`}
|
||||
className="btn btn-primary"
|
||||
style={{ textDecoration: 'none', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
style={{
|
||||
textDecoration: 'none',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
Im Training (Coach)
|
||||
</Link>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => window.print()}>
|
||||
Drucken / PDF
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" title="Alle Häkchen auf dieser Matte zurücksetzen" onClick={() => confirm('Fortschritt wirklich zurücksetzen?') && clearProgress()}>
|
||||
{printStreamOptions.length > 0 ? (
|
||||
<label
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
fontSize: '0.86rem',
|
||||
color: 'var(--text2)',
|
||||
}}
|
||||
>
|
||||
Druck / Vorschau
|
||||
<select
|
||||
className="form-input"
|
||||
style={{ marginBottom: 0, minWidth: '200px', fontSize: '0.86rem' }}
|
||||
value={printOnlyStreamId || ''}
|
||||
onChange={(e) => setPrintOnlyStreamId(e.target.value || null)}
|
||||
>
|
||||
<option value="">Gesamter Plan (empfohlen)</option>
|
||||
{printStreamOptions.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
Nur: {o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
title="Alle Häkchen auf dieser Matte zurücksetzen"
|
||||
onClick={() => confirm('Fortschritt wirklich zurücksetzen?') && clearProgress()}
|
||||
>
|
||||
Fortschritt leeren
|
||||
</button>
|
||||
</nav>
|
||||
|
|
@ -201,10 +478,81 @@ export default function TrainingUnitRunPage() {
|
|||
</span>
|
||||
{totalPlannedMin > 0 && (
|
||||
<span>
|
||||
<strong>Geplante Zeit (Übungen):</strong> ca. {totalPlannedMin} Min.
|
||||
<strong>Geplante Zeit (Übungen, gesamt):</strong> ca. {totalPlannedMin} Min.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{printOnlyStreamId ? (
|
||||
<div
|
||||
className="no-print"
|
||||
style={{
|
||||
marginTop: '0.75rem',
|
||||
padding: '0.5rem 0.75rem',
|
||||
background: 'hsl(200 40% 94%)',
|
||||
borderRadius: '8px',
|
||||
fontSize: '0.82rem',
|
||||
color: 'hsl(200 25% 28%)',
|
||||
}}
|
||||
>
|
||||
Vorschau wie fürs Drucken: nur die gewählte Split-Gruppe. Ganzgruppen-Blöcke sind ausgeblendet.
|
||||
</div>
|
||||
) : null}
|
||||
{planModel.mode === 'phased' && planModel.runs.length > 0 && showWholeGroupInView && (
|
||||
<div
|
||||
className="training-run-phase-schedule training-run-notes-print"
|
||||
style={{
|
||||
marginTop: '1rem',
|
||||
padding: '0.75rem 0.9rem',
|
||||
background: 'var(--surface2)',
|
||||
borderRadius: '8px',
|
||||
fontSize: '0.86rem',
|
||||
overflowX: 'auto',
|
||||
}}
|
||||
>
|
||||
<strong style={{ display: 'block', marginBottom: '0.5rem', color: 'var(--text1)' }}>
|
||||
Zeitplanung (Summe Übungsminuten)
|
||||
</strong>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: '280px' }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: 'left', color: 'var(--text3)', fontSize: '0.78rem' }}>
|
||||
<th style={{ padding: '4px 8px 4px 0', fontWeight: 600 }}>Block</th>
|
||||
<th style={{ padding: '4px 8px', fontWeight: 600 }}>Art</th>
|
||||
<th style={{ padding: '4px 8px', fontWeight: 600 }}> Min</th>
|
||||
<th style={{ padding: '4px 0 4px 8px', fontWeight: 600 }}>∑ bis hier</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{planModel.runs.map((run, ri) => {
|
||||
const cum = planModel.runCumulativeEnds[ri]
|
||||
const label =
|
||||
run.phaseTitle != null && String(run.phaseTitle).trim()
|
||||
? String(run.phaseTitle).trim()
|
||||
: run.kind === 'legacy'
|
||||
? 'Ablauf'
|
||||
: run.kind === 'whole_group'
|
||||
? `Ganzgruppe (Phase ${run.phaseOrderIndex})`
|
||||
: `Parallel (Phase ${run.phaseOrderIndex})`
|
||||
return (
|
||||
<tr key={`sched-${ri}-${run.kind}`} style={{ borderTop: '1px solid var(--border)' }}>
|
||||
<td style={{ padding: '6px 8px 6px 0', verticalAlign: 'top' }}>{label}</td>
|
||||
<td style={{ padding: '6px 8px', color: 'var(--text2)', whiteSpace: 'nowrap' }}>
|
||||
{run.kind === 'parallel' ? 'Split' : run.kind === 'legacy' ? '—' : 'Ganzgruppe'}
|
||||
</td>
|
||||
<td style={{ padding: '6px 8px' }}>{run.minutes || '—'}</td>
|
||||
<td style={{ padding: '6px 0 6px 8px', fontWeight: 600 }}>{cum}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{unit.planned_time_start && (
|
||||
<p style={{ margin: '0.55rem 0 0', fontSize: '0.78rem', color: 'var(--text3)', lineHeight: 1.4 }}>
|
||||
Hinweis: Startzeit oben im Kopf; Minuten sind aus den Übungseinplanungen — ohne Pausen und ohne
|
||||
automatische Uhrzeitliste.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{unit.notes && (
|
||||
<div
|
||||
className="training-run-notes-print"
|
||||
|
|
@ -213,7 +561,7 @@ export default function TrainingUnitRunPage() {
|
|||
padding: '0.65rem 0.85rem',
|
||||
background: 'var(--accent-light)',
|
||||
borderRadius: '8px',
|
||||
fontSize: '0.92rem'
|
||||
fontSize: '0.92rem',
|
||||
}}
|
||||
>
|
||||
<strong>Hinweis Teilnehmer:</strong> {unit.notes}
|
||||
|
|
@ -226,171 +574,154 @@ export default function TrainingUnitRunPage() {
|
|||
Noch keine Abschnitte in diesem Plan. Unter <Link to="/planning">Planung</Link> bearbeiten.
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
|
||||
{sections.map((sec, si) => {
|
||||
const secOrder = sec.order_index ?? si
|
||||
const items = sortedItems(sec)
|
||||
return (
|
||||
<section key={sec.id ?? `sec-${si}`} className="card training-run-section" style={{ padding: '1.15rem 1.25rem' }}>
|
||||
<h2 style={{ fontSize: '1.05rem', marginBottom: '0.65rem', color: 'var(--accent-dark)' }}>
|
||||
{sec.title || `Abschnitt ${si + 1}`}
|
||||
</h2>
|
||||
{sec.guidance_notes && (
|
||||
<p style={{ fontSize: '0.88rem', color: 'var(--text2)', marginBottom: '0.85rem', whiteSpace: 'pre-wrap' }}>
|
||||
{sec.guidance_notes}
|
||||
</p>
|
||||
)}
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: '0.65rem' }}>
|
||||
{items.map((it, ii) => {
|
||||
const ck = itemStableKey(it, secOrder, ii)
|
||||
const done = checked.has(ck)
|
||||
|
||||
if (it.item_type === 'note') {
|
||||
<div className="training-run-body" style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
|
||||
{planModel.runs.map((run, runIdx) => {
|
||||
if (run.kind === 'parallel') {
|
||||
const visibleStreams = run.streams.filter((st) => showStreamColumn(st.printStreamId))
|
||||
if (!visibleStreams.length) return null
|
||||
return (
|
||||
<div
|
||||
key={`run-p-${run.phaseOrderIndex}-${runIdx}`}
|
||||
className="training-run-phase training-run-phase--parallel"
|
||||
>
|
||||
<div
|
||||
className="card training-run-phase-banner"
|
||||
style={{
|
||||
padding: '0.85rem 1rem',
|
||||
background: 'linear-gradient(135deg, hsl(200 35% 94%), var(--surface2))',
|
||||
border: '1px solid hsl(200 40% 80%)',
|
||||
borderRadius: '10px',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '0.72rem', fontWeight: 700, color: 'var(--text3)', letterSpacing: '0.04em' }}>
|
||||
PARALLELE PHASE
|
||||
</div>
|
||||
<div style={{ fontSize: '1.02rem', fontWeight: 700, color: 'var(--accent-dark)', marginTop: '4px' }}>
|
||||
{run.phaseTitle ? String(run.phaseTitle).trim() : `Phase ${run.phaseOrderIndex}`}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.82rem', color: 'var(--text2)', marginTop: '4px' }}>
|
||||
{printOnlyStreamId
|
||||
? `Auszug eine Gruppe · ca. ${visibleStreams[0]?.minutes ?? run.minutes} Min. (Üb.)`
|
||||
: `Geplante Übungszeit (gesamt): ca. ${run.minutes} Min. · Jede Spalte kann separat gedruckt werden (Dropdown oder Seitenumbruch).`}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="training-run-parallel-columns"
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))',
|
||||
gap: '1rem',
|
||||
alignItems: 'start',
|
||||
}}
|
||||
>
|
||||
{visibleStreams.map((st, streamIdx) => {
|
||||
const siUnit = (sec) => Math.max(0, sections.indexOf(sec))
|
||||
return (
|
||||
<li key={ck} className={`training-run-item training-run-item--note${done ? ' training-run-item--done' : ''}`}>
|
||||
<label
|
||||
<div
|
||||
key={st.printStreamId}
|
||||
className={
|
||||
'training-run-breakout-stream' +
|
||||
(!printOnlyStreamId && streamIdx > 0
|
||||
? ' training-run-breakout-stream--page-break'
|
||||
: '')
|
||||
}
|
||||
data-print-id={st.printStreamId}
|
||||
>
|
||||
<div
|
||||
className="training-run-stream-ribbon"
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '0.75rem',
|
||||
alignItems: 'flex-start',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.92rem'
|
||||
marginBottom: '10px',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '8px',
|
||||
background: 'hsl(200 38% 92%)',
|
||||
border: '1px dashed hsl(200 42% 58%)',
|
||||
fontSize: '0.88rem',
|
||||
fontWeight: 700,
|
||||
color: 'hsl(200 30% 28%)',
|
||||
}}
|
||||
>
|
||||
<input type="checkbox" className="training-run-checkbox" checked={done} onChange={() => toggle(ck)} style={{ marginTop: '4px', width: '20px', height: '20px' }} />
|
||||
<span style={{ whiteSpace: 'pre-wrap', color: 'var(--text2)' }}>
|
||||
<em style={{ fontStyle: 'normal', color: 'var(--text3)', fontSize: '0.8rem' }}>Notiz</em>
|
||||
<br />
|
||||
{it.note_body || ''}
|
||||
{st.streamTitle ? String(st.streamTitle).trim() : `Gruppe ${st.streamOrder + 1}`}
|
||||
<span style={{ fontWeight: 500, marginLeft: '8px', opacity: 0.85 }}>
|
||||
· ca. {st.minutes} Min. (Üb.)
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
{st.sections.map((sec) => renderSectionCard(sec, siUnit(sec)))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const title =
|
||||
it.exercise_title ||
|
||||
(it.exercise_id ? `Übung #${it.exercise_id}` : 'Übung')
|
||||
const variant = it.exercise_variant_name ? ` (${it.exercise_variant_name})` : ''
|
||||
const plan = formatMin(it.planned_duration_min)
|
||||
const extras = []
|
||||
if (it.exercise_focus_area) extras.push(it.exercise_focus_area)
|
||||
const exKind = String(it.exercise_kind || 'simple').toLowerCase().trim()
|
||||
const isComboRow = exKind === 'combination'
|
||||
const metaParts = [...extras, isComboRow ? 'Kombination' : null, plan].filter(Boolean)
|
||||
const comboEffectiveProfile = isComboRow
|
||||
? effectiveComboMethodProfile(
|
||||
it.catalog_method_profile || {},
|
||||
it.planning_method_profile ?? null,
|
||||
)
|
||||
: null
|
||||
if (!showWholeGroupInView) return null
|
||||
|
||||
return (
|
||||
<li key={ck} className={`training-run-item training-run-item--exercise${done ? ' training-run-item--done' : ''}`}>
|
||||
<label
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '0.75rem',
|
||||
alignItems: 'flex-start',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
<input type="checkbox" className="training-run-checkbox" checked={done} onChange={() => toggle(ck)} style={{ marginTop: '6px', width: '22px', height: '22px', flexShrink: 0 }} />
|
||||
<span style={{ flex: 1, minWidth: 0 }}>
|
||||
<span style={{ fontSize: '1.02rem', fontWeight: 600 }}>
|
||||
{title}
|
||||
{variant}
|
||||
</span>
|
||||
{metaParts.length > 0 && (
|
||||
<div style={{ fontSize: '0.85rem', color: 'var(--text3)', marginTop: '3px', lineHeight: 1.35 }}>
|
||||
{metaParts.join(' · ')}
|
||||
</div>
|
||||
)}
|
||||
{(it.notes || it.modifications) && (
|
||||
<div style={{ marginTop: '0.35rem', fontSize: '0.88rem', color: 'var(--text2)', whiteSpace: 'pre-wrap' }}>
|
||||
{it.notes && (
|
||||
<>
|
||||
<strong style={{ fontWeight: 600 }}>Coach:</strong> {it.notes}
|
||||
</>
|
||||
)}
|
||||
{it.modifications && (
|
||||
<>
|
||||
{it.notes ? <br /> : null}
|
||||
<strong style={{ fontWeight: 600 }}>Anpassung:</strong> {it.modifications}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isComboRow && it.exercise_id ? (
|
||||
<div className="training-run-combo-embed">
|
||||
<CombinationPlanBracket
|
||||
methodArchetype={String(it.catalog_method_archetype || '').trim()}
|
||||
methodProfile={comboEffectiveProfile || {}}
|
||||
combinationSlots={
|
||||
Array.isArray(it.combination_slots) ? it.combination_slots : []
|
||||
}
|
||||
planningAdjusted={
|
||||
it.planning_method_profile != null &&
|
||||
typeof it.planning_method_profile === 'object' &&
|
||||
!Array.isArray(it.planning_method_profile)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{it.exercise_id && (
|
||||
<div className="no-print" style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', marginTop: '0.55rem', alignItems: 'center' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: '0.82rem', margin: 0 }}
|
||||
onClick={() =>
|
||||
setPeekCtx({
|
||||
exerciseId: it.exercise_id,
|
||||
variantId:
|
||||
it.exercise_variant_id != null
|
||||
? Number(it.exercise_variant_id)
|
||||
: null,
|
||||
peekExtras: isComboRow
|
||||
? {
|
||||
catalog_method_profile: it.catalog_method_profile,
|
||||
planning_method_profile: it.planning_method_profile,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
Katalog (Popup)
|
||||
</button>
|
||||
<Link
|
||||
to={`/exercises/${it.exercise_id}`}
|
||||
style={{ fontSize: '0.82rem', color: 'var(--accent)' }}
|
||||
>
|
||||
Vollständige Seite öffnen
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
return (
|
||||
<div
|
||||
key={`run-wg-${run.phaseOrderIndex}-${runIdx}`}
|
||||
className="training-run-phase training-run-phase--whole training-run-wg-block"
|
||||
>
|
||||
{run.kind !== 'legacy' ? (
|
||||
<div
|
||||
className="card training-run-phase-banner"
|
||||
style={{
|
||||
padding: '0.85rem 1rem',
|
||||
marginBottom: '0.25rem',
|
||||
background: 'color-mix(in srgb, var(--accent) 12%, var(--surface2))',
|
||||
border: '1px solid color-mix(in srgb, var(--accent) 28%, var(--border))',
|
||||
borderRadius: '10px',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '0.72rem', fontWeight: 700, color: 'var(--text3)', letterSpacing: '0.04em' }}>
|
||||
GANZGRUPPE
|
||||
</div>
|
||||
<div style={{ fontSize: '1.02rem', fontWeight: 700, color: 'var(--accent-dark)', marginTop: '4px' }}>
|
||||
{run.phaseTitle ? String(run.phaseTitle).trim() : `Phase ${run.phaseOrderIndex}`}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.82rem', color: 'var(--text2)', marginTop: '4px' }}>
|
||||
Geplante Übungszeit: ca. {run.minutes} Min.
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
|
||||
{run.sections.map((sec) => renderSectionCard(sec, Math.max(0, sections.indexOf(sec))))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{unit.trainer_notes && (
|
||||
<div className="card training-run-trainer-note no-print" style={{ marginTop: '1.25rem', padding: '1rem', borderLeft: `4px solid var(--accent)` }}>
|
||||
<div style={{ fontSize: '0.75rem', fontWeight: 700, color: 'var(--text3)', marginBottom: '0.35rem', textTransform: 'uppercase' }}>
|
||||
<div
|
||||
className="card training-run-trainer-note no-print"
|
||||
style={{
|
||||
marginTop: '1.25rem',
|
||||
padding: '1rem',
|
||||
borderLeft: `4px solid var(--accent)`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 700,
|
||||
color: 'var(--text3)',
|
||||
marginBottom: '0.35rem',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
Nur Trainer
|
||||
</div>
|
||||
<p style={{ fontSize: '0.92rem', whiteSpace: 'pre-wrap' }}>{unit.trainer_notes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<footer className="no-print training-run-footer" style={{ marginTop: '1.75rem', textAlign: 'center', fontSize: '0.82rem', color: 'var(--text3)' }}>
|
||||
<footer
|
||||
className="no-print training-run-footer"
|
||||
style={{ marginTop: '1.75rem', textAlign: 'center', fontSize: '0.82rem', color: 'var(--text3)' }}
|
||||
>
|
||||
Haken werden nur auf diesem Gerät gespeichert (Session — Tab schließen kann sie löschen).
|
||||
</footer>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,12 @@
|
|||
* Hilfen für Trainingsplan-Ansicht, Coach-Modus und API-Payload für PUT /training-units/:id.
|
||||
*/
|
||||
|
||||
import { cloneJsonSerializablePlanningProfile } from './trainingUnitSectionsForm'
|
||||
import {
|
||||
cloneJsonSerializablePlanningProfile,
|
||||
phaseRunsFromSections,
|
||||
sectionIndicesForParallelStream,
|
||||
streamsForParallelPhaseOrders,
|
||||
} from './trainingUnitSectionsForm'
|
||||
export function sortedSections(unit) {
|
||||
const raw = unit?.sections
|
||||
if (!Array.isArray(raw)) return []
|
||||
|
|
@ -20,11 +25,123 @@ export function itemStableKey(it, secOrder, ix) {
|
|||
return `${secOrder}-${it?.item_type || 'row'}-${ix}`
|
||||
}
|
||||
|
||||
/** Flache Reihenfolge wie auf der Matte: alle Notizen und Übungen nacheinander.
|
||||
* Parallele Streams: aktuell strikt linear (sortedSections × sortedItems); für Breakout phase/stream‑aware erweitern. */
|
||||
export function sumExerciseMinutesInSection(sec) {
|
||||
let t = 0
|
||||
for (const it of sortedItems(sec)) {
|
||||
if (it.item_type === 'exercise' && it.planned_duration_min != null) {
|
||||
const n = Number(it.planned_duration_min)
|
||||
if (Number.isFinite(n)) t += n
|
||||
}
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
/**
|
||||
* Lesemodell für Plan & Ablauf / Druck / Coach: Phasenläufe mit Ganzgruppe vs. Split.
|
||||
* Legacy ohne planLoc: ein Block.
|
||||
*/
|
||||
export function buildPlanRunViewModel(unit) {
|
||||
const sections = sortedSections(unit)
|
||||
if (!sections.length) {
|
||||
return { mode: 'empty', runs: [], totalMin: 0, runCumulativeEnds: [] }
|
||||
}
|
||||
|
||||
const runsMeta = phaseRunsFromSections(sections)
|
||||
const runs = []
|
||||
let cum = 0
|
||||
const runCumulativeEnds = []
|
||||
|
||||
if (runsMeta.length === 0) {
|
||||
const minutes = sections.reduce((s, sec) => s + sumExerciseMinutesInSection(sec), 0)
|
||||
cum = minutes
|
||||
runCumulativeEnds.push(cum)
|
||||
runs.push({
|
||||
kind: 'legacy',
|
||||
phaseOrderIndex: 0,
|
||||
phaseTitle: null,
|
||||
minutes,
|
||||
sections,
|
||||
streams: null,
|
||||
globalOrderSections: sections,
|
||||
})
|
||||
return { mode: 'legacy', runs, totalMin: minutes, runCumulativeEnds }
|
||||
}
|
||||
|
||||
for (const r of runsMeta) {
|
||||
const slice = sections.slice(r.start, r.end)
|
||||
if (r.phaseKind === 'whole_group') {
|
||||
const minutes = slice.reduce((s, sec) => s + sumExerciseMinutesInSection(sec), 0)
|
||||
cum += minutes
|
||||
runCumulativeEnds.push(cum)
|
||||
const phaseTitle = slice[0]?.planLoc?.phaseTitle ?? null
|
||||
runs.push({
|
||||
kind: 'whole_group',
|
||||
phaseOrderIndex: r.phaseOrderIndex,
|
||||
phaseTitle,
|
||||
minutes,
|
||||
sections: slice,
|
||||
streams: null,
|
||||
globalOrderSections: slice,
|
||||
})
|
||||
} else {
|
||||
const po = r.phaseOrderIndex
|
||||
const streamOrders = streamsForParallelPhaseOrders(slice, po)
|
||||
const streams = streamOrders.map((so) => {
|
||||
const idxs = sectionIndicesForParallelStream(slice, po, so)
|
||||
const streamSecs = idxs.map((i) => slice[i])
|
||||
const first = streamSecs[0]
|
||||
const streamTitle = first?.planLoc?.streamTitle ?? null
|
||||
const minutes = streamSecs.reduce((s, sec) => s + sumExerciseMinutesInSection(sec), 0)
|
||||
return {
|
||||
streamOrder: so,
|
||||
streamTitle,
|
||||
minutes,
|
||||
sections: streamSecs,
|
||||
printStreamId: `p${po}-s${so}`,
|
||||
}
|
||||
})
|
||||
const minutes = slice.reduce((s, sec) => s + sumExerciseMinutesInSection(sec), 0)
|
||||
cum += minutes
|
||||
runCumulativeEnds.push(cum)
|
||||
const phaseTitle = slice[0]?.planLoc?.phaseTitle ?? null
|
||||
runs.push({
|
||||
kind: 'parallel',
|
||||
phaseOrderIndex: po,
|
||||
phaseTitle,
|
||||
minutes,
|
||||
streams,
|
||||
sections: null,
|
||||
globalOrderSections: slice,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { mode: 'phased', runs, totalMin: cum, runCumulativeEnds }
|
||||
}
|
||||
|
||||
function coachContextLabelForSection(sec, sectionsList) {
|
||||
const pl = sec?.planLoc
|
||||
if (!pl?.phaseKind) return 'Ablauf'
|
||||
if (pl.phaseKind === 'whole_group') {
|
||||
const pt = pl.phaseTitle != null && String(pl.phaseTitle).trim() ? String(pl.phaseTitle).trim() : null
|
||||
return pt ? `Ganzgruppe · ${pt}` : 'Ganzgruppe'
|
||||
}
|
||||
const pt = pl.phaseTitle != null && String(pl.phaseTitle).trim() ? String(pl.phaseTitle).trim() : `Phase ${pl.phaseOrderIndex ?? 0}`
|
||||
const so = pl.parallelStreamOrderIndex ?? 0
|
||||
const st = pl.streamTitle != null && String(pl.streamTitle).trim() ? String(pl.streamTitle).trim() : `Gruppe ${so + 1}`
|
||||
return `Parallel · ${pt} · ${st}`
|
||||
}
|
||||
|
||||
/** Flache Reihenfolge für Coach-Timeline (global wie im Editor, inkl. gemischter Split-Abschnitte). */
|
||||
export function flattenPlanTimeline(unit) {
|
||||
const model = buildPlanRunViewModel(unit)
|
||||
if (model.mode === 'empty') return []
|
||||
|
||||
const sections = sortedSections(unit)
|
||||
const list = []
|
||||
sortedSections(unit).forEach((sec, si) => {
|
||||
|
||||
const pushSectionItems = (sec, coachCtx) => {
|
||||
const si = Math.max(0, sections.indexOf(sec))
|
||||
const secOrder = sec.order_index ?? si
|
||||
sortedItems(sec).forEach((item, ii) => {
|
||||
list.push({
|
||||
|
|
@ -34,9 +151,17 @@ export function flattenPlanTimeline(unit) {
|
|||
flatIndex: list.length,
|
||||
sec,
|
||||
item,
|
||||
coachContext: coachCtx,
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
for (const run of model.runs) {
|
||||
for (const sec of run.globalOrderSections) {
|
||||
pushSectionItems(sec, coachContextLabelForSection(sec, sections))
|
||||
}
|
||||
}
|
||||
|
||||
return list
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user