From 09a07b510b997f9bab23c8aa5888b90337a8812f Mon Sep 17 00:00:00 2001 From: Docker Config Backup Date: Thu, 17 Sep 2026 13:12:49 +0200 Subject: [PATCH] feat: auto-save drafts + publish step for viewers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Editors' changes are now auto-saved (5 s after the last edit, at most 30 s after the first unsaved one, retried on failure) with a status indicator; leaving the page with unsaved changes prompts a warning. Each schedule file keeps a published snapshot next to the draft. Viewer/cis roles load and export only the published snapshot; the editor's draft becomes visible to them only after "Publikovat". Toolbar shows the publish state ("Nepublikované změny" / "Publikováno HH:MM"), the file manager shows the publish date, and viewers pick up a new publish automatically within a minute. Server: GET /api/files/:id?view=published, POST /api/files/:id/publish, ?view=published on all exports, one-time migration that seeds the published snapshot from the current data on startup. Co-Authored-By: Claude Fable 5.1 --- web/server.js | 103 ++++++++++++++++++++++++++------- web/src/App.tsx | 123 +++++++++++++++++++++++++++++++++++++--- web/src/FileManager.tsx | 14 +++++ web/src/Toolbar.tsx | 81 ++++++++++++++++++++++++++ web/src/types.ts | 2 + 5 files changed, 294 insertions(+), 29 deletions(-) diff --git a/web/server.js b/web/server.js index 19825c1..3c37544 100644 --- a/web/server.js +++ b/web/server.js @@ -46,12 +46,7 @@ function listScheduleFiles() { for (const f of files) { try { const raw = JSON.parse(readFileSync(join(SCHEDULES_DIR, f), 'utf-8')) - result.push({ - id: raw.id, - name: raw.name, - createdAt: raw.createdAt, - modifiedAt: raw.modifiedAt, - }) + result.push(fileMeta(raw)) } catch {} } result.sort((a, b) => new Date(b.modifiedAt) - new Date(a.modifiedAt)) @@ -69,6 +64,41 @@ function saveScheduleFile(fileObj) { writeFileSync(join(SCHEDULES_DIR, `${fileObj.id}.json`), JSON.stringify(fileObj, null, 2), 'utf-8') } +// --- Draft vs. published --- +// file.data = working copy (editor's draft, auto-saved) +// file.published = snapshot viewers see; updated only by POST /api/files/:id/publish + +function ensurePublished(file) { + // Files created before publishing existed: their current data becomes the published snapshot + if (!file.published) { + file.published = JSON.parse(JSON.stringify(file.data)) + file.publishedAt = file.modifiedAt || new Date().toISOString() + return true + } + return false +} + +function hasUnpublishedChanges(file) { + if (!file.published) return true + return JSON.stringify(file.data) !== JSON.stringify(file.published) +} + +function fileMeta(file) { + return { + id: file.id, + name: file.name, + createdAt: file.createdAt, + modifiedAt: file.modifiedAt, + publishedAt: file.publishedAt ?? null, + hasUnpublishedChanges: hasUnpublishedChanges(file), + } +} + +// Pick draft or published data for a request (?view=published) +function pickData(file, view) { + return view === 'published' && file.published ? file.published : file.data +} + function getMostRecentFile() { const files = listScheduleFiles() return files.length > 0 ? files[0] : null @@ -97,6 +127,15 @@ function migrateIfNeeded() { migrateIfNeeded() +// Give every existing file a published snapshot (one-time, idempotent) +for (const f of listScheduleFiles()) { + const file = loadScheduleFile(f.id) + if (file && ensurePublished(file)) { + saveScheduleFile(file) + console.log(`Published snapshot initialised for ${file.name} (${file.id})`) + } +} + // --- Diff computation --- function computeDiff(file1, file2) { @@ -187,7 +226,8 @@ app.get('/api/files/:id', (req, res) => { try { const file = loadScheduleFile(req.params.id) if (!file) return res.status(404).json({ error: 'File not found' }) - res.json(file) + const view = req.query.view === 'published' ? 'published' : 'draft' + res.json({ ...fileMeta(file), view, data: pickData(file, view) }) } catch (err) { console.error('Error loading file:', err) res.status(500).json({ error: 'Failed to load file' }) @@ -207,7 +247,7 @@ app.get('/api/files/:id/export-excel', async (req, res) => { try { fileData = JSON.parse(readFileSync(filePath, 'utf8')) } catch { return res.status(500).json({ error: 'Failed to read file' }) } - const input = JSON.stringify({ schedule: fileData.data, month, year }) + const input = JSON.stringify({ schedule: pickData(fileData, req.query.view), month, year }) const scriptPath = join(__dirname, 'export_full_excel.py') try { @@ -257,7 +297,7 @@ app.get('/api/files/:id/export-harmonogram', async (req, res) => { try { fileData = JSON.parse(readFileSync(filePath, 'utf8')) } catch { return res.status(500).json({ error: 'Failed to read file' }) } - const input = JSON.stringify({ schedule: fileData.data, month, year }) + const input = JSON.stringify({ schedule: pickData(fileData, req.query.view), month, year }) const scriptPath = join(__dirname, 'export_harmonogram.py') try { @@ -306,7 +346,7 @@ app.get('/api/files/:id/export-dochazka', async (req, res) => { try { fileData = JSON.parse(readFileSync(filePath, 'utf8')) } catch { return res.status(500).json({ error: 'Failed to read file' }) } - const input = JSON.stringify({ schedule: fileData.data, month, year }) + const input = JSON.stringify({ schedule: pickData(fileData, req.query.view), month, year }) const scriptPath = join(__dirname, 'export_dochazka.py') try { @@ -356,14 +396,11 @@ app.post('/api/files', (req, res) => { createdAt: now, modifiedAt: now, data, + published: JSON.parse(JSON.stringify(data)), + publishedAt: now, } saveScheduleFile(fileObj) - res.json({ - id: fileObj.id, - name: fileObj.name, - createdAt: fileObj.createdAt, - modifiedAt: fileObj.modifiedAt, - }) + res.json(fileMeta(fileObj)) } catch (err) { console.error('Error creating file:', err) res.status(500).json({ error: 'Failed to create file' }) @@ -387,18 +424,40 @@ app.put('/api/files/:id', (req, res) => { file.modifiedAt = new Date().toISOString() saveScheduleFile(file) - res.json({ - id: file.id, - name: file.name, - createdAt: file.createdAt, - modifiedAt: file.modifiedAt, - }) + res.json(fileMeta(file)) } catch (err) { console.error('Error updating file:', err) res.status(500).json({ error: 'Failed to update file' }) } }) +// Publish: optionally save the given draft, then make it the version viewers see +app.post('/api/files/:id/publish', (req, res) => { + try { + const file = loadScheduleFile(req.params.id) + if (!file) return res.status(404).json({ error: 'File not found' }) + + const now = new Date().toISOString() + const { data } = req.body || {} + if (data) { + if ((!data.people && !data.stations) || !data.dayIndex) { + return res.status(400).json({ error: 'Invalid schedule data (need people/stations and dayIndex)' }) + } + file.data = data + file.modifiedAt = now + } + file.published = JSON.parse(JSON.stringify(file.data)) + file.publishedAt = now + saveScheduleFile(file) + console.log(`Published ${file.name} (${file.id}) at ${now}`) + + res.json(fileMeta(file)) + } catch (err) { + console.error('Error publishing file:', err) + res.status(500).json({ error: 'Failed to publish file' }) + } +}) + // Delete file app.delete('/api/files/:id', (req, res) => { try { diff --git a/web/src/App.tsx b/web/src/App.tsx index c6cb063..24a3960 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -138,19 +138,22 @@ function App() { const [fileId, setFileId] = useState(null) const [fileName, setFileName] = useState('') const [fileData, setFileData] = useState(null) + const [fileMeta, setFileMeta] = useState<{ publishedAt: string | null; hasUnpublishedChanges: boolean }>({ publishedAt: null, hasUnpublishedChanges: false }) const [loading, setLoading] = useState(hasFavOnLoad || viewerAutoLoad) const [compareFileId, setCompareFileId] = useState(null) const autoOpenAttempted = useRef(false) + // Viewers/cis only ever see the published snapshot; editors work on the draft const handleOpenFile = useCallback(async (id: string, compareId?: string) => { setLoading(true) try { - const res = await fetch(`/api/files/${id}`) + const res = await fetch(`/api/files/${id}${isReadOnlyRole ? '?view=published' : ''}`) if (!res.ok) throw new Error('Failed to load file') const file: ScheduleFileWithData = await res.json() setFileId(file.id) setFileName(file.name) setFileData(normalizeDayIndex(file.data)) + setFileMeta({ publishedAt: file.publishedAt ?? null, hasUnpublishedChanges: !!file.hasUnpublishedChanges }) setCompareFileId(compareId ?? null) setMode('editor') } catch (err) { @@ -159,7 +162,23 @@ function App() { } finally { setLoading(false) } - }, []) + }, [isReadOnlyRole]) + + // Viewers: pick up a new publish automatically (poll metadata, reload when publishedAt changes) + useEffect(() => { + if (!isReadOnlyRole || mode !== 'editor' || !fileId) return + const check = async () => { + try { + const res = await fetch('/api/files') + if (!res.ok) return + const files: { id: string; publishedAt?: string | null }[] = await res.json() + const me = files.find(f => f.id === fileId) + if (me && (me.publishedAt ?? null) !== fileMeta.publishedAt) handleOpenFile(fileId) + } catch {} + } + const interval = setInterval(check, 60_000) + return () => clearInterval(interval) + }, [isReadOnlyRole, mode, fileId, fileMeta.publishedAt, handleOpenFile]) // Auto-open favourite file when authenticated; for viewers with no favourite, auto-open most recent useEffect(() => { @@ -267,7 +286,12 @@ function App() { if (mode === 'editor' && fileData && fileId) { return ( void + publishedAt?: string | null + hasUnpublishedChanges?: boolean + viewPublished?: boolean // data shown is the published snapshot (viewer/cis) } -function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileNameChange, onClearCompare, isReadOnly = false, canExportHarmonogram = true, onLogout }: ScheduleAppProps) { +const AUTOSAVE_DELAY_MS = 5_000 // save this long after the last change +const AUTOSAVE_MAX_WAIT_MS = 30_000 // ...but never later than this after the first unsaved change + +function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileNameChange, onClearCompare, isReadOnly = false, canExportHarmonogram = true, onLogout, publishedAt: initialPublishedAt = null, hasUnpublishedChanges: initialUnpublished = false, viewPublished = false }: ScheduleAppProps) { const { people, tunnelClosures, tunnelColors, metroClosures, metroColors, d8Closures, d8Colors, sazltClosures, sazltColors, @@ -349,6 +379,13 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName const [contextMenu, setContextMenu] = useState(null) const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle') + const [dirty, setDirty] = useState(false) // unsaved changes exist + const [lastSavedAt, setLastSavedAt] = useState(null) + const [publishedAt, setPublishedAt] = useState(initialPublishedAt) + const [unpublished, setUnpublished] = useState(initialUnpublished) + const [publishStatus, setPublishStatus] = useState<'idle' | 'publishing' | 'published' | 'error'>('idle') + const dirtySinceRef = useRef(null) + const savingRef = useRef(false) const [selectedDenDays, setSelectedDenDays] = useState([]) const [currentFileId, setCurrentFileId] = useState(fileId) const [compareData, setCompareData] = useState(null) @@ -462,7 +499,9 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName }) }, [data.dayIndex]) - const handleSave = useCallback(async () => { + const handleSave = useCallback(async (): Promise => { + if (savingRef.current) return false + savingRef.current = true setSaveStatus('saving') try { const payload = getSchedulePayload() @@ -481,14 +520,78 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName }) if (!res.ok) throw new Error('Save failed') } + setDirty(false) + dirtySinceRef.current = null + setLastSavedAt(new Date()) setSaveStatus('saved') setTimeout(() => setSaveStatus('idle'), 3000) + return true } catch { setSaveStatus('error') setTimeout(() => setSaveStatus('idle'), 4000) + return false + } finally { + savingRef.current = false } }, [getSchedulePayload, currentFileId]) + // Any state change (getSchedulePayload identity changes with every edit) marks the draft dirty + const firstPayloadRef = useRef(true) + useEffect(() => { + if (firstPayloadRef.current) { firstPayloadRef.current = false; return } + if (isReadOnly) return + if (dirtySinceRef.current === null) dirtySinceRef.current = Date.now() + setDirty(true) + setUnpublished(true) + }, [getSchedulePayload, isReadOnly]) + + // Auto-save: debounce after the last change, with an upper bound since the first unsaved change + const saveRef = useRef(handleSave) + useEffect(() => { saveRef.current = handleSave }, [handleSave]) + useEffect(() => { + if (!dirty || isReadOnly || !currentFileId) return + const since = dirtySinceRef.current ?? Date.now() + const wait = Math.max(0, Math.min(AUTOSAVE_DELAY_MS, since + AUTOSAVE_MAX_WAIT_MS - Date.now())) + const t = setTimeout(() => { saveRef.current() }, wait) + return () => clearTimeout(t) + }, [dirty, getSchedulePayload, isReadOnly, currentFileId]) + + // Retry a failed auto-save after a while; warn before leaving with unsaved changes + useEffect(() => { + if (!dirty || isReadOnly) return + const retry = setInterval(() => { if (!savingRef.current) saveRef.current() }, AUTOSAVE_MAX_WAIT_MS) + const onBeforeUnload = (e: BeforeUnloadEvent) => { e.preventDefault(); e.returnValue = '' } + window.addEventListener('beforeunload', onBeforeUnload) + return () => { clearInterval(retry); window.removeEventListener('beforeunload', onBeforeUnload) } + }, [dirty, isReadOnly]) + + // Publish: save the current draft and make it the version viewers see + const handlePublish = useCallback(async () => { + if (!currentFileId) return + if (!confirm('Publikovat aktuální stav plánu?\nUživatelé s náhledem (prohlížeč) uvidí tuto verzi.')) return + setPublishStatus('publishing') + try { + const payload = getSchedulePayload() + const res = await fetch(`/api/files/${currentFileId}/publish`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ data: payload }), + }) + if (!res.ok) throw new Error('Publish failed') + const meta = await res.json() + setPublishedAt(meta.publishedAt ?? new Date().toISOString()) + setUnpublished(false) + setDirty(false) + dirtySinceRef.current = null + setLastSavedAt(new Date()) + setPublishStatus('published') + setTimeout(() => setPublishStatus('idle'), 3000) + } catch { + setPublishStatus('error') + setTimeout(() => setPublishStatus('idle'), 4000) + } + }, [currentFileId, getSchedulePayload]) + const handleSaveAs = useCallback(async () => { const name = prompt('Nazev noveho souboru:', fileName ? `${fileName} - kopie` : '') if (!name) return @@ -735,6 +838,12 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName onSave={handleSave} onSaveAs={handleSaveAs} saveStatus={saveStatus} + dirty={dirty} + lastSavedAt={lastSavedAt} + onPublish={currentFileId && !isReadOnly ? handlePublish : undefined} + publishStatus={publishStatus} + publishedAt={publishedAt} + hasUnpublished={unpublished} onScrollToMonth={handleScrollToMonth} activeMonth={activeMonth} showMetro={showMetro} @@ -760,7 +869,7 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName onExportPdf={handleExportPdf} onExportExcel={currentFileId ? (month: number) => { const year = data.dayIndex.find(d => d.month === month)?.year ?? new Date().getFullYear() - window.location.href = `/api/files/${currentFileId}/export-excel?month=${month}&year=${year}` + window.location.href = `/api/files/${currentFileId}/export-excel?month=${month}&year=${year}${viewPublished ? '&view=published' : ''}` } : undefined} onShowProposals={isReadOnly ? undefined : () => setShowProposals(true)} onCheckFpd={() => setShowFpd(true)} @@ -865,11 +974,11 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName onClose={() => setShowFpd(false)} onExportDochazka={currentFileId && !isReadOnly ? () => { const year = data.dayIndex.find(d => d.month === activeMonth)?.year ?? 2026 - window.location.href = `/api/files/${currentFileId}/export-dochazka?month=${activeMonth}&year=${year}` + window.location.href = `/api/files/${currentFileId}/export-dochazka?month=${activeMonth}&year=${year}${viewPublished ? '&view=published' : ''}` } : undefined} onExportHarmonogram={currentFileId && canExportHarmonogram ? () => { const year = data.dayIndex.find(d => d.month === activeMonth)?.year ?? 2026 - window.location.href = `/api/files/${currentFileId}/export-harmonogram?month=${activeMonth}&year=${year}` + window.location.href = `/api/files/${currentFileId}/export-harmonogram?month=${activeMonth}&year=${year}${viewPublished ? '&view=published' : ''}` } : undefined} /> )} diff --git a/web/src/FileManager.tsx b/web/src/FileManager.tsx index 6d8569f..6fd774b 100644 --- a/web/src/FileManager.tsx +++ b/web/src/FileManager.tsx @@ -238,6 +238,7 @@ export function FileManager({ onOpenFile, onCompare, onCreateNew, onLogout }: Fi Název Upraveno + Publikováno Vytvořeno Akce @@ -282,6 +283,19 @@ export function FileManager({ onOpenFile, onCompare, onCreateNew, onLogout }: Fi {formatDate(file.modifiedAt)} + +
+ {file.publishedAt ? formatDate(file.publishedAt) : '—'} + {file.hasUnpublishedChanges && ( + + nepublikované změny + + )} +
+ {formatDate(file.createdAt)} diff --git a/web/src/Toolbar.tsx b/web/src/Toolbar.tsx index 8298700..2663736 100644 --- a/web/src/Toolbar.tsx +++ b/web/src/Toolbar.tsx @@ -16,12 +16,29 @@ const MONTH_BUTTONS = [ { month: 12, label: 'Pro' }, ] +function formatStamp(d: Date | string | null | undefined): string { + if (!d) return '' + const dt = typeof d === 'string' ? new Date(d) : d + if (isNaN(dt.getTime())) return '' + const hh = String(dt.getHours()).padStart(2, '0') + const mm = String(dt.getMinutes()).padStart(2, '0') + const today = new Date() + const sameDay = dt.getFullYear() === today.getFullYear() && dt.getMonth() === today.getMonth() && dt.getDate() === today.getDate() + return sameDay ? `${hh}:${mm}` : `${dt.getDate()}. ${dt.getMonth() + 1}. ${hh}:${mm}` +} + interface ToolbarProps { onUndo: () => void canUndo: boolean onSave: () => void onSaveAs: () => void saveStatus: 'idle' | 'saving' | 'saved' | 'error' + dirty?: boolean // unsaved changes (auto-save pending) + lastSavedAt?: Date | null + onPublish?: () => void + publishStatus?: 'idle' | 'publishing' | 'published' | 'error' + publishedAt?: string | null + hasUnpublished?: boolean // draft differs from what viewers see onScrollToMonth: (month: number) => void activeMonth?: number showMetro: boolean @@ -59,6 +76,12 @@ export function Toolbar({ onSave, onSaveAs, saveStatus, + dirty = false, + lastSavedAt = null, + onPublish, + publishStatus = 'idle', + publishedAt = null, + hasUnpublished = false, onScrollToMonth, activeMonth, showMetro, @@ -185,9 +208,67 @@ export function Toolbar({ > Uložit jako + + {/* Auto-save state */} + + {saveStatus === 'error' ? 'Uložení selhalo, zkusím znovu' + : dirty ? 'Neuložené změny…' + : lastSavedAt ? `Uloženo ${formatStamp(lastSavedAt)}` + : ''} + + + {onPublish && ( + <> +
+ + + {hasUnpublished + ? '● Nepublikované změny' + : publishedAt ? `Publikováno ${formatStamp(publishedAt)}` : 'Nepublikováno'} + + + )} )} + {isReadOnly && publishedAt && ( + + Publikováno {formatStamp(publishedAt)} + + )} + {onExportPdf && activeMonth && (