From 177975c70cd82ab56fed66ebec4cb1bacda962cb Mon Sep 17 00:00:00 2001 From: Docker Config Backup Date: Thu, 2 Apr 2026 10:08:53 +0200 Subject: [PATCH] =?UTF-8?q?milestone:=20MVP=20=E2=80=94=20stable=20layout,?= =?UTF-8?q?=20all=20core=20features=20working?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- web/server.js | 6 +- web/src/ScheduleTable.tsx | 503 +++++++++++++++++++------------------- 2 files changed, 257 insertions(+), 252 deletions(-) diff --git a/web/server.js b/web/server.js index 66469c0..26f101f 100644 --- a/web/server.js +++ b/web/server.js @@ -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') } })) diff --git a/web/src/ScheduleTable.tsx b/web/src/ScheduleTable.tsx index ac09531..edc6264 100644 --- a/web/src/ScheduleTable.tsx +++ b/web/src/ScheduleTable.tsx @@ -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 ( -
-
- - {person.name} - {person.note && ({person.note})} - -
+
{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) => ( +
+ {label} +
+ ) + + // Helper: render info row cells (right column) + const renderInfoRowCells = ( rowId: '__tunnel__' | '__metro__' | '__d8__', - label: string, closures: Map, colors: Map, defaultBgClass: string, borderClass: string, editBorderColor: string, + label: string, contextHandler: (dayIdx: number, x: number, y: number) => void, onStartEditFn: (dayIdx: number, currentValue: string) => void, ) => (
-
- {label} -
{dayIndex.map((d) => { const closureVal = closures.get(d.idx) ?? '' const closureColor = colors.get(d.idx) @@ -638,8 +628,8 @@ export function ScheduleTable(props: ScheduleTableProps) {
) + // Helper: render section header (left column) + const renderSectionHeaderLabel = (label: string, bgClass: string, textClass: string) => ( +
+ {label} +
+ ) + + // Helper: render section header bar (right column) + const renderSectionHeaderBar = (bgClass: string) => ( +
+ ) + return ( -
-
+
+
- {/* === STICKY HEADER ROWS === */} -
- - {/* Month row */} -
-
- Měsíc -
- {monthSpans.map(m => ( -
- {MONTH_NAMES[m.month]} {m.year} -
- ))} + {/* === Fixed left name column === */} +
+ {/* Header labels */} +
+ Mesic +
+
+ Tyden +
+
+ Den +
+
+ Den t.
- {/* Week row */} -
-
- Týden -
- {weekSpans.map((w, i) => ( -
- {w.week} -
- ))} -
+ {/* TKB info row label */} + {renderInfoRowLabel('TKB', 'border-slate-600')} - {/* Day number row */} -
-
- Den -
- {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 ( -
- {d.day} - {(comment || holidayName) && ( - - )} -
- ) - })} -
+ {/* Metro info row label */} + {showMetro && renderInfoRowLabel('Metro', 'border-slate-700')} - {/* Day name row */} -
-
- Den t. -
- {dayIndex.map((d, i) => { - const isHoliday = holidays.has(`${d.year}-${d.month}-${d.day}`) - const isOff = d.weekend || isHoliday - return ( -
- {dayNames[i]} -
- ) - })} -
- - {/* 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)} - -
{/* end sticky header rows */} - - {/* Data rows container (with overlay) */} -
+ {/* D8 info row label */} + {showD8 && renderInfoRowLabel('D8', 'border-slate-600')} {/* TKB section header */} -
-
- Pohotovost TKB -
-
-
+ {renderSectionHeaderLabel('Pohotovost TKB', 'bg-indigo-700/60', 'text-indigo-100')} - {/* TKB person rows */} + {/* TKB person names */} {tkbPeople.map((person) => ( - +
+ + {person.name} + {person.note && ({person.note})} + +
))} {/* IT section header */} -
-
- Pohotovost IT -
-
-
+ {renderSectionHeaderLabel('Pohotovost IT', 'bg-teal-700/60', 'text-teal-100')} - {/* IT person rows */} + {/* IT person names */} {itPeople.map((person) => ( - +
+ + {person.name} + {person.note && ({person.note})} + +
))} - - {/* 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 ( -
- {gb.value} -
- ) - })} - - {/* Drag overlay */} - {dragOverlay}
+ + {/* === Scrollable grid === */} +
+
+ + {/* Month header row */} +
+ {monthSpans.map(m => ( +
+ {MONTH_NAMES[m.month]} {m.year} +
+ ))} +
+ + {/* Week header row */} +
+ {weekSpans.map((w, i) => ( +
+ {w.week} +
+ ))} +
+ + {/* Day number header row */} +
+ {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 ( +
+ {d.day} + {(comment || holidayName) && ( + + )} +
+ ) + })} +
+ + {/* Day name header row */} +
+ {dayIndex.map((d, i) => { + const isHoliday = holidays.has(`${d.year}-${d.month}-${d.day}`) + const isOff = d.weekend || isHoliday + return ( +
+ {dayNames[i]} +
+ ) + })} +
+ + {/* 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) */} +
+ + {/* TKB section header bar */} + {renderSectionHeaderBar('bg-indigo-700/60')} + + {/* TKB person rows */} + {tkbPeople.map((person) => ( + + ))} + + {/* IT section header bar */} + {renderSectionHeaderBar('bg-teal-700/60')} + + {/* IT person rows */} + {itPeople.map((person) => ( + + ))} + + {/* 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 ( +
+ {gb.value} +
+ ) + })} + + {/* Drag overlay */} + {dragOverlay} +
+
+
+
)