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,11 +374,9 @@ app.get('/api/export-excel', (_req, res) => {
// Serve static files from dist/ // Serve static files from dist/
app.use(express.static(join(__dirname, 'dist'), { app.use(express.static(join(__dirname, 'dist'), {
setHeaders: (res, path) => { setHeaders: (res) => {
if (path.endsWith('.html')) {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate') res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate')
} }
}
})) }))
// SPA fallback // SPA fallback

View File

@@ -72,7 +72,6 @@ const PersonRow = memo(function PersonRow({
onCellClick, onCellClick,
onCellDragSelectStart, onCellDragSelectStart,
onCellDragSelectMove, onCellDragSelectMove,
nameColW,
}: { }: {
person: Person person: Person
dayIndex: DayInfo[] dayIndex: DayInfo[]
@@ -92,21 +91,11 @@ const PersonRow = memo(function PersonRow({
onCellClick: (personId: string, dayIdx: number, ctrlKey: boolean, shiftKey: boolean) => void onCellClick: (personId: string, dayIdx: number, ctrlKey: boolean, shiftKey: boolean) => void
onCellDragSelectStart: (personId: string, dayIdx: number) => void onCellDragSelectStart: (personId: string, dayIdx: number) => void
onCellDragSelectMove: (personId: string, dayIdx: number) => void onCellDragSelectMove: (personId: string, dayIdx: number) => void
nameColW: number
}) { }) {
const isDragging = dragState?.personId === person.id const isDragging = dragState?.personId === person.id
return ( return (
<div className="flex border-b border-slate-700" style={{ height: CELL_H }}> <div className="flex border-b border-slate-700/50" 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>
{dayIndex.map((d) => { {dayIndex.map((d) => {
const cellData = person.data[String(d.idx)] const cellData = person.data[String(d.idx)]
const value = cellData?.v ?? '' const value = cellData?.v ?? ''
@@ -126,7 +115,7 @@ const PersonRow = memo(function PersonRow({
const bgStyle: React.CSSProperties = {} const bgStyle: React.CSSProperties = {}
let className = `flex items-center justify-center text-sm font-mono font-bold relative 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' : ''}` ${isMonthStart ? 'border-l-2 border-l-slate-500' : ''}`
if (isDragOriginal) { if (isDragOriginal) {
@@ -517,7 +506,7 @@ export function ScheduleTable(props: ScheduleTableProps) {
} }
const pos = idxToPos.get(dragState.previewIdx) ?? 0 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) const style = getCellStyle(dragState.value)
@@ -603,25 +592,26 @@ export function ScheduleTable(props: ScheduleTableProps) {
const totalGridW = dayIndex.length * CELL_W const totalGridW = dayIndex.length * CELL_W
const nameColW = 200 const nameColW = 200
// Helper: render an info row (TKB/Metro/D8 closures) // Helper: render info row label (left column)
const renderInfoRow = ( 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__', rowId: '__tunnel__' | '__metro__' | '__d8__',
label: string,
closures: Map<number, string>, closures: Map<number, string>,
colors: Map<number, string>, colors: Map<number, string>,
defaultBgClass: string, defaultBgClass: string,
borderClass: string, borderClass: string,
editBorderColor: string, editBorderColor: string,
label: string,
contextHandler: (dayIdx: number, x: number, y: number) => void, contextHandler: (dayIdx: number, x: number, y: number) => void,
onStartEditFn: (dayIdx: number, currentValue: string) => void, onStartEditFn: (dayIdx: number, currentValue: string) => void,
) => ( ) => (
<div className={`flex border-b ${borderClass}`} style={{ height: 28 }}> <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) => { {dayIndex.map((d) => {
const closureVal = closures.get(d.idx) ?? '' const closureVal = closures.get(d.idx) ?? ''
const closureColor = colors.get(d.idx) const closureColor = colors.get(d.idx)
@@ -638,8 +628,8 @@ export function ScheduleTable(props: ScheduleTableProps) {
<div <div
key={d.idx} key={d.idx}
className={`flex items-center justify-center text-xs font-mono font-bold relative className={`flex items-center justify-center text-xs font-mono font-bold relative
border-r border-slate-700 border-r border-slate-700/20
${!closureColor && closureVal ? `${defaultBgClass}` : !closureColor ? (isOff ? 'bg-slate-700' : 'bg-slate-800') : ''} ${!closureColor && closureVal ? `${defaultBgClass}` : !closureColor ? (isOff ? 'bg-slate-700/30' : 'bg-slate-800/30') : ''}
${d.day === 1 ? 'border-l-2 border-l-slate-500' : ''} ${d.day === 1 ? 'border-l-2 border-l-slate-500' : ''}
cursor-pointer hover:brightness-125 cursor-pointer hover:brightness-125
`} `}
@@ -675,21 +665,80 @@ export function ScheduleTable(props: ScheduleTableProps) {
</div> </div>
) )
return ( // Helper: render section header (left column)
<div ref={scrollRef} className="rounded-lg border border-slate-700 bg-slate-800/50 overflow-auto" style={{ maxHeight: 'calc(100vh - 160px)' }}> const renderSectionHeaderLabel = (label: string, bgClass: string, textClass: string) => (
<div style={{ width: nameColW + totalGridW, position: 'relative' }}> <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>
{/* === 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> </div>
)
// Helper: render section header bar (right column)
const renderSectionHeaderBar = (bgClass: string) => (
<div className={`${bgClass}`} style={{ height: CELL_H, width: totalGridW }} />
)
return (
<div className="rounded-lg border border-slate-700 bg-slate-800/50 overflow-hidden">
<div className="flex">
{/* === 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>
{/* TKB info row label */}
{renderInfoRowLabel('TKB', 'border-slate-600')}
{/* Metro info row label */}
{showMetro && renderInfoRowLabel('Metro', 'border-slate-700')}
{/* D8 info row label */}
{showD8 && renderInfoRowLabel('D8', 'border-slate-600')}
{/* TKB section header */}
{renderSectionHeaderLabel('Pohotovost TKB', 'bg-indigo-700/60', 'text-indigo-100')}
{/* TKB person names */}
{tkbPeople.map((person) => (
<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 */}
{renderSectionHeaderLabel('Pohotovost IT', 'bg-teal-700/60', 'text-teal-100')}
{/* IT person names */}
{itPeople.map((person) => (
<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>
))}
</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 => ( {monthSpans.map(m => (
<div <div
key={`${m.year}-${m.month}`} key={`${m.year}-${m.month}`}
@@ -701,18 +750,12 @@ export function ScheduleTable(props: ScheduleTableProps) {
))} ))}
</div> </div>
{/* Week row */} {/* Week header row */}
<div className="flex border-b border-slate-700" style={{ height: 24 }}> <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) => ( {weekSpans.map((w, i) => (
<div <div
key={`w-${i}`} key={`w-${i}`}
className="flex items-center justify-center text-[10px] text-slate-400 border-r border-slate-700 bg-slate-800" 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 }} style={{ width: w.count * CELL_W }}
> >
{w.week} {w.week}
@@ -720,14 +763,8 @@ export function ScheduleTable(props: ScheduleTableProps) {
))} ))}
</div> </div>
{/* Day number row */} {/* Day number header row */}
<div className="flex border-b border-slate-700" style={{ height: 28 }}> <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) => { {dayIndex.map((d) => {
const comment = dayComments.get(d.idx) const comment = dayComments.get(d.idx)
const holidayName = holidays.get(`${d.year}-${d.month}-${d.day}`) const holidayName = holidays.get(`${d.year}-${d.month}-${d.day}`)
@@ -737,7 +774,7 @@ export function ScheduleTable(props: ScheduleTableProps) {
<div <div
key={d.idx} key={d.idx}
className={`flex items-center justify-center text-[10px] font-mono relative 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'} ${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' : ''} ${d.day === 1 ? 'border-l-2 border-l-slate-500' : ''}
`} `}
style={{ width: CELL_W }} style={{ width: CELL_W }}
@@ -752,14 +789,8 @@ export function ScheduleTable(props: ScheduleTableProps) {
})} })}
</div> </div>
{/* Day name row */} {/* Day name header row */}
<div className="flex border-b border-slate-700" style={{ height: 24 }}> <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) => { {dayIndex.map((d, i) => {
const isHoliday = holidays.has(`${d.year}-${d.month}-${d.day}`) const isHoliday = holidays.has(`${d.year}-${d.month}-${d.day}`)
const isOff = d.weekend || isHoliday const isOff = d.weekend || isHoliday
@@ -767,7 +798,7 @@ export function ScheduleTable(props: ScheduleTableProps) {
<div <div
key={d.idx} key={d.idx}
className={`flex items-center justify-center text-[9px] font-mono className={`flex items-center justify-center text-[9px] font-mono
${isOff ? 'text-red-400/70 bg-slate-700' : 'text-slate-500 bg-slate-800'} ${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' : ''} ${d.day === 1 ? 'border-l-2 border-l-slate-500' : ''}
`} `}
style={{ width: CELL_W }} style={{ width: CELL_W }}
@@ -778,39 +809,26 @@ export function ScheduleTable(props: ScheduleTableProps) {
})} })}
</div> </div>
{/* TKB closures row */} {/* TKB info row */}
{renderInfoRow('__tunnel__', 'TKB', tunnelClosures, tunnelColors, {renderInfoRowCells('__tunnel__', tunnelClosures, tunnelColors,
'bg-orange-700/40 text-orange-200', 'border-slate-600', 'border-orange-400', 'bg-orange-700/40 text-orange-200', 'border-slate-600', 'border-orange-400', 'TKB',
onTunnelContextMenu, onStartEditTunnel)} onTunnelContextMenu, onStartEditTunnel)}
{/* Metro row */} {/* Metro info row */}
{showMetro && renderInfoRow('__metro__', 'Metro', metroClosures, metroColors, {showMetro && renderInfoRowCells('__metro__', metroClosures, metroColors,
'bg-blue-700/40 text-blue-200', 'border-slate-700', 'border-blue-400', 'bg-blue-700/40 text-blue-200', 'border-slate-700', 'border-blue-400', 'Metro',
(dayIdx, x, y) => onInfoRowContextMenu(dayIdx, 'metro', x, y), onStartEditMetro)} (dayIdx, x, y) => onInfoRowContextMenu(dayIdx, 'metro', x, y), onStartEditMetro)}
{/* D8 row */} {/* D8 info row */}
{showD8 && renderInfoRow('__d8__', 'D8', d8Closures, d8Colors, {showD8 && renderInfoRowCells('__d8__', d8Closures, d8Colors,
'bg-green-700/40 text-green-200', 'border-slate-600', 'border-green-400', 'bg-green-700/40 text-green-200', 'border-slate-600', 'border-green-400', 'D8',
(dayIdx, x, y) => onInfoRowContextMenu(dayIdx, 'd8', x, y), onStartEditD8)} (dayIdx, x, y) => onInfoRowContextMenu(dayIdx, 'd8', x, y), onStartEditD8)}
</div>{/* end sticky header rows */}
{/* Data rows container (with overlay) */} {/* Data rows container (with overlay) */}
<div style={{ position: 'relative' }}> <div style={{ position: 'relative' }}>
{/* TKB section header */} {/* TKB section header bar */}
<div className="flex" style={{ height: CELL_H }}> {renderSectionHeaderBar('bg-indigo-700/60')}
<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>
{/* TKB person rows */} {/* TKB person rows */}
{tkbPeople.map((person) => ( {tkbPeople.map((person) => (
@@ -834,23 +852,11 @@ export function ScheduleTable(props: ScheduleTableProps) {
onCellClick={onCellClick} onCellClick={onCellClick}
onCellDragSelectStart={onCellDragSelectStart} onCellDragSelectStart={onCellDragSelectStart}
onCellDragSelectMove={onCellDragSelectMove} onCellDragSelectMove={onCellDragSelectMove}
nameColW={nameColW}
/> />
))} ))}
{/* IT section header */} {/* IT section header bar */}
<div className="flex" style={{ height: CELL_H }}> {renderSectionHeaderBar('bg-teal-700/60')}
<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>
{/* IT person rows */} {/* IT person rows */}
{itPeople.map((person) => ( {itPeople.map((person) => (
@@ -874,20 +880,18 @@ export function ScheduleTable(props: ScheduleTableProps) {
onCellClick={onCellClick} onCellClick={onCellClick}
onCellDragSelectStart={onCellDragSelectStart} onCellDragSelectStart={onCellDragSelectStart}
onCellDragSelectMove={onCellDragSelectMove} onCellDragSelectMove={onCellDragSelectMove}
nameColW={nameColW}
/> />
))} ))}
{/* Compare ghost blocks */} {/* Compare ghost blocks */}
{ghostBlocks && ghostBlocks.map((gb, i) => { {ghostBlocks && ghostBlocks.map((gb, i) => {
const left = nameColW + gb.startPos * CELL_W const left = gb.startPos * CELL_W
// Account for section headers in top position
const tkbCount = tkbPeople.length const tkbCount = tkbPeople.length
let top: number let top: number
if (gb.personIdx < tkbCount) { if (gb.personIdx < tkbCount) {
top = (1 + gb.personIdx) * CELL_H // +1 for TKB section header top = (1 + gb.personIdx) * CELL_H
} else { } else {
top = (1 + tkbCount + 1 + (gb.personIdx - tkbCount)) * CELL_H // +1 TKB header +1 IT header top = (1 + tkbCount + 1 + (gb.personIdx - tkbCount)) * CELL_H
} }
const width = gb.length * CELL_W const width = gb.length * CELL_W
const cellStyle = getCellStyle(gb.value) const cellStyle = getCellStyle(gb.value)
@@ -917,5 +921,8 @@ export function ScheduleTable(props: ScheduleTableProps) {
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
) )
} }