milestone: MVP — stable layout, all core features working

Reverted sticky header attempt back to clean two-column layout.
Server now sends no-cache headers for all static files.

Working features:
- People grid (18 TKB + 5 IT) with year-long calendar
- Shift codes (4/6/8/12/A/B/D/N/U/O) with auto-colored backgrounds
- Drag-and-drop cells (including color-only cells)
- Multi-cell selection (click/ctrl/shift/drag) with bulk operations
- Right-click context menu: set value, color palette, comments
- Info rows: TKB, Metro, D8 (toggleable)
- Czech holidays highlighted with names
- Cell value filter toggles in legend
- PDF export (2-page A4 landscape, Czech font support)
- Improvement proposals system
- Auto-contrast text color on manual backgrounds
- Opens at current month on load

TODO: sticky header (needs different approach)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Docker Config Backup
2026-04-02 10:08:53 +02:00
parent b4158d687f
commit 177975c70c
2 changed files with 257 additions and 252 deletions

View File

@@ -374,10 +374,8 @@ app.get('/api/export-excel', (_req, res) => {
// Serve static files from dist/
app.use(express.static(join(__dirname, 'dist'), {
setHeaders: (res, path) => {
if (path.endsWith('.html')) {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate')
}
setHeaders: (res) => {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate')
}
}))

View File

@@ -72,7 +72,6 @@ const PersonRow = memo(function PersonRow({
onCellClick,
onCellDragSelectStart,
onCellDragSelectMove,
nameColW,
}: {
person: Person
dayIndex: DayInfo[]
@@ -92,21 +91,11 @@ const PersonRow = memo(function PersonRow({
onCellClick: (personId: string, dayIdx: number, ctrlKey: boolean, shiftKey: boolean) => void
onCellDragSelectStart: (personId: string, dayIdx: number) => void
onCellDragSelectMove: (personId: string, dayIdx: number) => void
nameColW: number
}) {
const isDragging = dragState?.personId === person.id
return (
<div className="flex border-b border-slate-700" style={{ height: CELL_H }}>
<div
className="sticky left-0 z-20 bg-slate-800 border-r border-slate-600 border-b border-b-slate-600 flex items-center px-3 flex-shrink-0"
style={{ width: nameColW, minWidth: nameColW }}
>
<span className="text-xs text-slate-200 whitespace-nowrap truncate">
{person.name}
{person.note && <span className="text-slate-500 ml-1">({person.note})</span>}
</span>
</div>
<div className="flex border-b border-slate-700/50" style={{ height: CELL_H }}>
{dayIndex.map((d) => {
const cellData = person.data[String(d.idx)]
const value = cellData?.v ?? ''
@@ -126,7 +115,7 @@ const PersonRow = memo(function PersonRow({
const bgStyle: React.CSSProperties = {}
let className = `flex items-center justify-center text-sm font-mono font-bold relative
border-r border-slate-700
border-r border-slate-700/20
${isMonthStart ? 'border-l-2 border-l-slate-500' : ''}`
if (isDragOriginal) {
@@ -517,7 +506,7 @@ export function ScheduleTable(props: ScheduleTableProps) {
}
const pos = idxToPos.get(dragState.previewIdx) ?? 0
const left = 200 + pos * CELL_W // 200 = nameColW
const left = pos * CELL_W
const style = getCellStyle(dragState.value)
@@ -603,25 +592,26 @@ export function ScheduleTable(props: ScheduleTableProps) {
const totalGridW = dayIndex.length * CELL_W
const nameColW = 200
// Helper: render an info row (TKB/Metro/D8 closures)
const renderInfoRow = (
// Helper: render info row label (left column)
const renderInfoRowLabel = (label: string, borderClass: string) => (
<div className={`bg-slate-800 border-r border-slate-600 border-b ${borderClass} flex items-center px-3`} style={{ height: 28 }}>
<span className="text-[10px] uppercase tracking-wider text-slate-500 font-semibold">{label}</span>
</div>
)
// Helper: render info row cells (right column)
const renderInfoRowCells = (
rowId: '__tunnel__' | '__metro__' | '__d8__',
label: string,
closures: Map<number, string>,
colors: Map<number, string>,
defaultBgClass: string,
borderClass: string,
editBorderColor: string,
label: string,
contextHandler: (dayIdx: number, x: number, y: number) => void,
onStartEditFn: (dayIdx: number, currentValue: string) => void,
) => (
<div className={`flex border-b ${borderClass}`} style={{ height: 28 }}>
<div
className={`sticky left-0 z-20 bg-slate-800 border-r border-slate-600 border-b ${borderClass} flex items-center px-3 flex-shrink-0`}
style={{ width: nameColW, minWidth: nameColW }}
>
<span className="text-[10px] uppercase tracking-wider text-slate-500 font-semibold">{label}</span>
</div>
{dayIndex.map((d) => {
const closureVal = closures.get(d.idx) ?? ''
const closureColor = colors.get(d.idx)
@@ -638,8 +628,8 @@ export function ScheduleTable(props: ScheduleTableProps) {
<div
key={d.idx}
className={`flex items-center justify-center text-xs font-mono font-bold relative
border-r border-slate-700
${!closureColor && closureVal ? `${defaultBgClass}` : !closureColor ? (isOff ? 'bg-slate-700' : 'bg-slate-800') : ''}
border-r border-slate-700/20
${!closureColor && closureVal ? `${defaultBgClass}` : !closureColor ? (isOff ? 'bg-slate-700/30' : 'bg-slate-800/30') : ''}
${d.day === 1 ? 'border-l-2 border-l-slate-500' : ''}
cursor-pointer hover:brightness-125
`}
@@ -675,246 +665,263 @@ export function ScheduleTable(props: ScheduleTableProps) {
</div>
)
// Helper: render section header (left column)
const renderSectionHeaderLabel = (label: string, bgClass: string, textClass: string) => (
<div className={`${bgClass} border-r border-slate-600 flex items-center px-3`} style={{ height: CELL_H }}>
<span className={`text-xs font-bold ${textClass} uppercase tracking-wider`}>{label}</span>
</div>
)
// Helper: render section header bar (right column)
const renderSectionHeaderBar = (bgClass: string) => (
<div className={`${bgClass}`} style={{ height: CELL_H, width: totalGridW }} />
)
return (
<div ref={scrollRef} className="rounded-lg border border-slate-700 bg-slate-800/50 overflow-auto" style={{ maxHeight: 'calc(100vh - 160px)' }}>
<div style={{ width: nameColW + totalGridW, position: 'relative' }}>
<div className="rounded-lg border border-slate-700 bg-slate-800/50 overflow-hidden">
<div className="flex">
{/* === STICKY HEADER ROWS === */}
<div className="sticky top-0 z-30 bg-slate-800">
{/* Month row */}
<div className="flex border-b border-slate-700" style={{ height: 28 }}>
<div
className="sticky left-0 z-20 bg-slate-800 border-r border-slate-600 border-b border-b-slate-600 flex items-center px-3 flex-shrink-0"
style={{ width: nameColW, minWidth: nameColW }}
>
<span className="text-[10px] uppercase tracking-wider text-slate-500 font-semibold">Měsíc</span>
</div>
{monthSpans.map(m => (
<div
key={`${m.year}-${m.month}`}
className="flex items-center justify-center text-xs font-semibold text-slate-300 border-r border-slate-600 bg-slate-800"
style={{ width: m.count * CELL_W }}
>
{MONTH_NAMES[m.month]} {m.year}
</div>
))}
{/* === Fixed left name column === */}
<div className="flex-shrink-0 z-20 bg-slate-800 border-r border-slate-600" style={{ width: nameColW }}>
{/* Header labels */}
<div className="bg-slate-800 border-b border-slate-700 flex items-center px-3" style={{ height: 28 }}>
<span className="text-[10px] uppercase tracking-wider text-slate-500 font-semibold">Mesic</span>
</div>
<div className="bg-slate-800 border-b border-slate-700 flex items-center px-3" style={{ height: 24 }}>
<span className="text-[10px] uppercase tracking-wider text-slate-500 font-semibold">Tyden</span>
</div>
<div className="bg-slate-800 border-b border-slate-700 flex items-center px-3" style={{ height: 28 }}>
<span className="text-[10px] uppercase tracking-wider text-slate-500 font-semibold">Den</span>
</div>
<div className="bg-slate-800 border-b border-slate-700 flex items-center px-3" style={{ height: 24 }}>
<span className="text-[10px] uppercase tracking-wider text-slate-500 font-semibold">Den t.</span>
</div>
{/* Week row */}
<div className="flex border-b border-slate-700" style={{ height: 24 }}>
<div
className="sticky left-0 z-20 bg-slate-800 border-r border-slate-600 border-b border-b-slate-600 flex items-center px-3 flex-shrink-0"
style={{ width: nameColW, minWidth: nameColW }}
>
<span className="text-[10px] uppercase tracking-wider text-slate-500 font-semibold">Týden</span>
</div>
{weekSpans.map((w, i) => (
<div
key={`w-${i}`}
className="flex items-center justify-center text-[10px] text-slate-400 border-r border-slate-700 bg-slate-800"
style={{ width: w.count * CELL_W }}
>
{w.week}
</div>
))}
</div>
{/* TKB info row label */}
{renderInfoRowLabel('TKB', 'border-slate-600')}
{/* Day number row */}
<div className="flex border-b border-slate-700" style={{ height: 28 }}>
<div
className="sticky left-0 z-20 bg-slate-800 border-r border-slate-600 border-b border-b-slate-600 flex items-center px-3 flex-shrink-0"
style={{ width: nameColW, minWidth: nameColW }}
>
<span className="text-[10px] uppercase tracking-wider text-slate-500 font-semibold">Den</span>
</div>
{dayIndex.map((d) => {
const comment = dayComments.get(d.idx)
const holidayName = holidays.get(`${d.year}-${d.month}-${d.day}`)
const isHoliday = !!holidayName
const isOff = d.weekend || isHoliday
return (
<div
key={d.idx}
className={`flex items-center justify-center text-[10px] font-mono relative
${isOff ? 'text-red-400/70 bg-slate-700' : 'text-slate-400 bg-slate-800'}
${d.day === 1 ? 'border-l-2 border-l-slate-500' : ''}
`}
style={{ width: CELL_W }}
title={`${d.day}.${d.month}.${d.year}${holidayName ? `\n🎉 ${holidayName}` : ''}${comment ? `\n${comment}` : ''}`}
>
{d.day}
{(comment || holidayName) && (
<span className={`absolute bottom-0.5 left-1/2 -translate-x-1/2 w-1.5 h-1.5 rounded-full pointer-events-none ${holidayName ? 'bg-red-400' : 'bg-blue-500'}`} />
)}
</div>
)
})}
</div>
{/* Metro info row label */}
{showMetro && renderInfoRowLabel('Metro', 'border-slate-700')}
{/* Day name row */}
<div className="flex border-b border-slate-700" style={{ height: 24 }}>
<div
className="sticky left-0 z-20 bg-slate-800 border-r border-slate-600 border-b border-b-slate-600 flex items-center px-3 flex-shrink-0"
style={{ width: nameColW, minWidth: nameColW }}
>
<span className="text-[10px] uppercase tracking-wider text-slate-500 font-semibold">Den t.</span>
</div>
{dayIndex.map((d, i) => {
const isHoliday = holidays.has(`${d.year}-${d.month}-${d.day}`)
const isOff = d.weekend || isHoliday
return (
<div
key={d.idx}
className={`flex items-center justify-center text-[9px] font-mono
${isOff ? 'text-red-400/70 bg-slate-700' : 'text-slate-500 bg-slate-800'}
${d.day === 1 ? 'border-l-2 border-l-slate-500' : ''}
`}
style={{ width: CELL_W }}
>
{dayNames[i]}
</div>
)
})}
</div>
{/* TKB closures row */}
{renderInfoRow('__tunnel__', 'TKB', tunnelClosures, tunnelColors,
'bg-orange-700/40 text-orange-200', 'border-slate-600', 'border-orange-400',
onTunnelContextMenu, onStartEditTunnel)}
{/* Metro row */}
{showMetro && renderInfoRow('__metro__', 'Metro', metroClosures, metroColors,
'bg-blue-700/40 text-blue-200', 'border-slate-700', 'border-blue-400',
(dayIdx, x, y) => onInfoRowContextMenu(dayIdx, 'metro', x, y), onStartEditMetro)}
{/* D8 row */}
{showD8 && renderInfoRow('__d8__', 'D8', d8Closures, d8Colors,
'bg-green-700/40 text-green-200', 'border-slate-600', 'border-green-400',
(dayIdx, x, y) => onInfoRowContextMenu(dayIdx, 'd8', x, y), onStartEditD8)}
</div>{/* end sticky header rows */}
{/* Data rows container (with overlay) */}
<div style={{ position: 'relative' }}>
{/* D8 info row label */}
{showD8 && renderInfoRowLabel('D8', 'border-slate-600')}
{/* TKB section header */}
<div className="flex" style={{ height: CELL_H }}>
<div
className="sticky left-0 z-20 bg-indigo-700 border-r border-slate-600 flex items-center px-3 flex-shrink-0"
style={{ width: nameColW, minWidth: nameColW }}
>
<span className="text-xs font-bold text-indigo-100 uppercase tracking-wider">Pohotovost TKB</span>
</div>
<div
className="bg-indigo-700 flex items-center justify-center text-xs font-bold uppercase tracking-wider text-white"
style={{ width: totalGridW }}
/>
</div>
{renderSectionHeaderLabel('Pohotovost TKB', 'bg-indigo-700/60', 'text-indigo-100')}
{/* TKB person rows */}
{/* TKB person names */}
{tkbPeople.map((person) => (
<PersonRow
key={person.id}
person={person}
dayIndex={dayIndex}
dragState={dragState}
cellComments={cellComments}
dayComments={dayComments}
holidays={holidays}
hiddenValues={hiddenValues}
onCellPointerDown={onCellPointerDown}
onContextMenu={handlePersonContextMenu}
editingCell={editingCell}
onStartEdit={onStartEdit}
onEditChange={onEditChange}
onEditConfirm={onEditConfirm}
onEditCancel={onEditCancel}
selectedCells={selectedCells}
onCellClick={onCellClick}
onCellDragSelectStart={onCellDragSelectStart}
onCellDragSelectMove={onCellDragSelectMove}
nameColW={nameColW}
/>
<div key={person.id} className="bg-slate-800 border-b border-slate-700 flex items-center px-3" style={{ height: CELL_H }}>
<span className="text-xs text-slate-200 whitespace-nowrap truncate">
{person.name}
{person.note && <span className="text-slate-500 ml-1">({person.note})</span>}
</span>
</div>
))}
{/* IT section header */}
<div className="flex" style={{ height: CELL_H }}>
<div
className="sticky left-0 z-20 bg-teal-700 border-r border-slate-600 flex items-center px-3 flex-shrink-0"
style={{ width: nameColW, minWidth: nameColW }}
>
<span className="text-xs font-bold text-teal-100 uppercase tracking-wider">Pohotovost IT</span>
</div>
<div
className="bg-teal-700 flex items-center justify-center text-xs font-bold uppercase tracking-wider text-white"
style={{ width: totalGridW }}
/>
</div>
{renderSectionHeaderLabel('Pohotovost IT', 'bg-teal-700/60', 'text-teal-100')}
{/* IT person rows */}
{/* IT person names */}
{itPeople.map((person) => (
<PersonRow
key={person.id}
person={person}
dayIndex={dayIndex}
dragState={dragState}
cellComments={cellComments}
dayComments={dayComments}
holidays={holidays}
hiddenValues={hiddenValues}
onCellPointerDown={onCellPointerDown}
onContextMenu={handlePersonContextMenu}
editingCell={editingCell}
onStartEdit={onStartEdit}
onEditChange={onEditChange}
onEditConfirm={onEditConfirm}
onEditCancel={onEditCancel}
selectedCells={selectedCells}
onCellClick={onCellClick}
onCellDragSelectStart={onCellDragSelectStart}
onCellDragSelectMove={onCellDragSelectMove}
nameColW={nameColW}
/>
<div key={person.id} className="bg-slate-800 border-b border-slate-700 flex items-center px-3" style={{ height: CELL_H }}>
<span className="text-xs text-slate-200 whitespace-nowrap truncate">
{person.name}
{person.note && <span className="text-slate-500 ml-1">({person.note})</span>}
</span>
</div>
))}
{/* Compare ghost blocks */}
{ghostBlocks && ghostBlocks.map((gb, i) => {
const left = nameColW + gb.startPos * CELL_W
// Account for section headers in top position
const tkbCount = tkbPeople.length
let top: number
if (gb.personIdx < tkbCount) {
top = (1 + gb.personIdx) * CELL_H // +1 for TKB section header
} else {
top = (1 + tkbCount + 1 + (gb.personIdx - tkbCount)) * CELL_H // +1 TKB header +1 IT header
}
const width = gb.length * CELL_W
const cellStyle = getCellStyle(gb.value)
return (
<div
key={`ghost-${i}`}
className="absolute pointer-events-none flex items-center justify-center text-[10px] font-mono font-semibold"
style={{
left, top, width, height: CELL_H,
borderWidth: 2,
borderStyle: 'dashed',
borderRadius: 2,
borderColor: (cellStyle?.bg ?? '#64748b') + 'b3',
backgroundColor: (cellStyle?.bg ?? '#64748b') + '33',
color: (cellStyle?.text ?? '#94a3b8') + 'cc',
}}
title={`Bylo: ${gb.value} (${gb.length} dní)`}
>
{gb.value}
</div>
)
})}
{/* Drag overlay */}
{dragOverlay}
</div>
{/* === Scrollable grid === */}
<div ref={scrollRef} className="overflow-x-auto flex-1">
<div style={{ width: totalGridW, position: 'relative' }}>
{/* Month header row */}
<div className="flex border-b border-slate-700" style={{ height: 28 }}>
{monthSpans.map(m => (
<div
key={`${m.year}-${m.month}`}
className="flex items-center justify-center text-xs font-semibold text-slate-300 border-r border-slate-600 bg-slate-800"
style={{ width: m.count * CELL_W }}
>
{MONTH_NAMES[m.month]} {m.year}
</div>
))}
</div>
{/* Week header row */}
<div className="flex border-b border-slate-700" style={{ height: 24 }}>
{weekSpans.map((w, i) => (
<div
key={`w-${i}`}
className="flex items-center justify-center text-[10px] text-slate-400 border-r border-slate-700/50 bg-slate-800/80"
style={{ width: w.count * CELL_W }}
>
{w.week}
</div>
))}
</div>
{/* Day number header row */}
<div className="flex border-b border-slate-700" style={{ height: 28 }}>
{dayIndex.map((d) => {
const comment = dayComments.get(d.idx)
const holidayName = holidays.get(`${d.year}-${d.month}-${d.day}`)
const isHoliday = !!holidayName
const isOff = d.weekend || isHoliday
return (
<div
key={d.idx}
className={`flex items-center justify-center text-[10px] font-mono relative
${isOff ? 'text-red-400/70 bg-slate-700/30' : 'text-slate-400 bg-slate-800/50'}
${d.day === 1 ? 'border-l-2 border-l-slate-500' : ''}
`}
style={{ width: CELL_W }}
title={`${d.day}.${d.month}.${d.year}${holidayName ? `\n🎉 ${holidayName}` : ''}${comment ? `\n${comment}` : ''}`}
>
{d.day}
{(comment || holidayName) && (
<span className={`absolute bottom-0.5 left-1/2 -translate-x-1/2 w-1.5 h-1.5 rounded-full pointer-events-none ${holidayName ? 'bg-red-400' : 'bg-blue-500'}`} />
)}
</div>
)
})}
</div>
{/* Day name header row */}
<div className="flex border-b border-slate-700" style={{ height: 24 }}>
{dayIndex.map((d, i) => {
const isHoliday = holidays.has(`${d.year}-${d.month}-${d.day}`)
const isOff = d.weekend || isHoliday
return (
<div
key={d.idx}
className={`flex items-center justify-center text-[9px] font-mono
${isOff ? 'text-red-400/70 bg-slate-700/30' : 'text-slate-500 bg-slate-800/50'}
${d.day === 1 ? 'border-l-2 border-l-slate-500' : ''}
`}
style={{ width: CELL_W }}
>
{dayNames[i]}
</div>
)
})}
</div>
{/* TKB info row */}
{renderInfoRowCells('__tunnel__', tunnelClosures, tunnelColors,
'bg-orange-700/40 text-orange-200', 'border-slate-600', 'border-orange-400', 'TKB',
onTunnelContextMenu, onStartEditTunnel)}
{/* Metro info row */}
{showMetro && renderInfoRowCells('__metro__', metroClosures, metroColors,
'bg-blue-700/40 text-blue-200', 'border-slate-700', 'border-blue-400', 'Metro',
(dayIdx, x, y) => onInfoRowContextMenu(dayIdx, 'metro', x, y), onStartEditMetro)}
{/* D8 info row */}
{showD8 && renderInfoRowCells('__d8__', d8Closures, d8Colors,
'bg-green-700/40 text-green-200', 'border-slate-600', 'border-green-400', 'D8',
(dayIdx, x, y) => onInfoRowContextMenu(dayIdx, 'd8', x, y), onStartEditD8)}
{/* Data rows container (with overlay) */}
<div style={{ position: 'relative' }}>
{/* TKB section header bar */}
{renderSectionHeaderBar('bg-indigo-700/60')}
{/* TKB person rows */}
{tkbPeople.map((person) => (
<PersonRow
key={person.id}
person={person}
dayIndex={dayIndex}
dragState={dragState}
cellComments={cellComments}
dayComments={dayComments}
holidays={holidays}
hiddenValues={hiddenValues}
onCellPointerDown={onCellPointerDown}
onContextMenu={handlePersonContextMenu}
editingCell={editingCell}
onStartEdit={onStartEdit}
onEditChange={onEditChange}
onEditConfirm={onEditConfirm}
onEditCancel={onEditCancel}
selectedCells={selectedCells}
onCellClick={onCellClick}
onCellDragSelectStart={onCellDragSelectStart}
onCellDragSelectMove={onCellDragSelectMove}
/>
))}
{/* IT section header bar */}
{renderSectionHeaderBar('bg-teal-700/60')}
{/* IT person rows */}
{itPeople.map((person) => (
<PersonRow
key={person.id}
person={person}
dayIndex={dayIndex}
dragState={dragState}
cellComments={cellComments}
dayComments={dayComments}
holidays={holidays}
hiddenValues={hiddenValues}
onCellPointerDown={onCellPointerDown}
onContextMenu={handlePersonContextMenu}
editingCell={editingCell}
onStartEdit={onStartEdit}
onEditChange={onEditChange}
onEditConfirm={onEditConfirm}
onEditCancel={onEditCancel}
selectedCells={selectedCells}
onCellClick={onCellClick}
onCellDragSelectStart={onCellDragSelectStart}
onCellDragSelectMove={onCellDragSelectMove}
/>
))}
{/* Compare ghost blocks */}
{ghostBlocks && ghostBlocks.map((gb, i) => {
const left = gb.startPos * CELL_W
const tkbCount = tkbPeople.length
let top: number
if (gb.personIdx < tkbCount) {
top = (1 + gb.personIdx) * CELL_H
} else {
top = (1 + tkbCount + 1 + (gb.personIdx - tkbCount)) * CELL_H
}
const width = gb.length * CELL_W
const cellStyle = getCellStyle(gb.value)
return (
<div
key={`ghost-${i}`}
className="absolute pointer-events-none flex items-center justify-center text-[10px] font-mono font-semibold"
style={{
left, top, width, height: CELL_H,
borderWidth: 2,
borderStyle: 'dashed',
borderRadius: 2,
borderColor: (cellStyle?.bg ?? '#64748b') + 'b3',
backgroundColor: (cellStyle?.bg ?? '#64748b') + '33',
color: (cellStyle?.text ?? '#94a3b8') + 'cc',
}}
title={`Bylo: ${gb.value} (${gb.length} dní)`}
>
{gb.value}
</div>
)
})}
{/* Drag overlay */}
{dragOverlay}
</div>
</div>
</div>
</div>
</div>
)