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/
app.use(express.static(join(__dirname, 'dist'), {
setHeaders: (res, path) => {
if (path.endsWith('.html')) {
setHeaders: (res) => {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate')
}
}
}))
// SPA fallback

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,21 +665,80 @@ export function ScheduleTable(props: ScheduleTableProps) {
</div>
)
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' }}>
{/* === 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>
// 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 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 => (
<div
key={`${m.year}-${m.month}`}
@@ -701,18 +750,12 @@ export function ScheduleTable(props: ScheduleTableProps) {
))}
</div>
{/* Week row */}
{/* Week header 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"
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}
@@ -720,14 +763,8 @@ export function ScheduleTable(props: ScheduleTableProps) {
))}
</div>
{/* Day number row */}
{/* Day number header 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}`)
@@ -737,7 +774,7 @@ export function ScheduleTable(props: ScheduleTableProps) {
<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'}
${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 }}
@@ -752,14 +789,8 @@ export function ScheduleTable(props: ScheduleTableProps) {
})}
</div>
{/* Day name row */}
{/* Day name header 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
@@ -767,7 +798,7 @@ export function ScheduleTable(props: ScheduleTableProps) {
<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'}
${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 }}
@@ -778,39 +809,26 @@ export function ScheduleTable(props: ScheduleTableProps) {
})}
</div>
{/* TKB closures row */}
{renderInfoRow('__tunnel__', 'TKB', tunnelClosures, tunnelColors,
'bg-orange-700/40 text-orange-200', 'border-slate-600', 'border-orange-400',
{/* TKB info row */}
{renderInfoRowCells('__tunnel__', tunnelClosures, tunnelColors,
'bg-orange-700/40 text-orange-200', 'border-slate-600', 'border-orange-400', 'TKB',
onTunnelContextMenu, onStartEditTunnel)}
{/* Metro row */}
{showMetro && renderInfoRow('__metro__', 'Metro', metroClosures, metroColors,
'bg-blue-700/40 text-blue-200', 'border-slate-700', 'border-blue-400',
{/* 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 row */}
{showD8 && renderInfoRow('__d8__', 'D8', d8Closures, d8Colors,
'bg-green-700/40 text-green-200', 'border-slate-600', 'border-green-400',
{/* 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)}
</div>{/* end sticky header rows */}
{/* Data rows container (with overlay) */}
<div style={{ position: 'relative' }}>
{/* 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>
{/* TKB section header bar */}
{renderSectionHeaderBar('bg-indigo-700/60')}
{/* TKB person rows */}
{tkbPeople.map((person) => (
@@ -834,23 +852,11 @@ export function ScheduleTable(props: ScheduleTableProps) {
onCellClick={onCellClick}
onCellDragSelectStart={onCellDragSelectStart}
onCellDragSelectMove={onCellDragSelectMove}
nameColW={nameColW}
/>
))}
{/* 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>
{/* IT section header bar */}
{renderSectionHeaderBar('bg-teal-700/60')}
{/* IT person rows */}
{itPeople.map((person) => (
@@ -874,20 +880,18 @@ export function ScheduleTable(props: ScheduleTableProps) {
onCellClick={onCellClick}
onCellDragSelectStart={onCellDragSelectStart}
onCellDragSelectMove={onCellDragSelectMove}
nameColW={nameColW}
/>
))}
{/* Compare ghost blocks */}
{ghostBlocks && ghostBlocks.map((gb, i) => {
const left = nameColW + gb.startPos * CELL_W
// Account for section headers in top position
const left = gb.startPos * CELL_W
const tkbCount = tkbPeople.length
let top: number
if (gb.personIdx < tkbCount) {
top = (1 + gb.personIdx) * CELL_H // +1 for TKB section header
top = (1 + gb.personIdx) * CELL_H
} 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 cellStyle = getCellStyle(gb.value)
@@ -917,5 +921,8 @@ export function ScheduleTable(props: ScheduleTableProps) {
</div>
</div>
</div>
</div>
</div>
)
}