feat: auto-save drafts + publish step for viewers

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 <noreply@anthropic.com>
This commit is contained in:
Docker Config Backup
2026-09-17 13:12:49 +02:00
parent 3d306e641c
commit 09a07b510b
5 changed files with 294 additions and 29 deletions

View File

@@ -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 {

View File

@@ -138,19 +138,22 @@ function App() {
const [fileId, setFileId] = useState<string | null>(null)
const [fileName, setFileName] = useState<string>('')
const [fileData, setFileData] = useState<ScheduleData | null>(null)
const [fileMeta, setFileMeta] = useState<{ publishedAt: string | null; hasUnpublishedChanges: boolean }>({ publishedAt: null, hasUnpublishedChanges: false })
const [loading, setLoading] = useState(hasFavOnLoad || viewerAutoLoad)
const [compareFileId, setCompareFileId] = useState<string | null>(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 (
<ScheduleApp
// Remount viewers on a new publish so the grid shows the fresh snapshot
key={isReadOnlyRole ? `${fileId}:${fileMeta.publishedAt ?? ''}` : fileId}
fileId={fileId}
publishedAt={fileMeta.publishedAt}
hasUnpublishedChanges={fileMeta.hasUnpublishedChanges}
viewPublished={isReadOnlyRole}
fileName={fileName}
data={fileData}
compareFileId={compareFileId}
@@ -331,9 +355,15 @@ interface ScheduleAppProps {
isReadOnly?: boolean
canExportHarmonogram?: boolean
onLogout?: () => 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<ContextMenuState | null>(null)
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
const [dirty, setDirty] = useState(false) // unsaved changes exist
const [lastSavedAt, setLastSavedAt] = useState<Date | null>(null)
const [publishedAt, setPublishedAt] = useState<string | null>(initialPublishedAt)
const [unpublished, setUnpublished] = useState(initialUnpublished)
const [publishStatus, setPublishStatus] = useState<'idle' | 'publishing' | 'published' | 'error'>('idle')
const dirtySinceRef = useRef<number | null>(null)
const savingRef = useRef(false)
const [selectedDenDays, setSelectedDenDays] = useState<number[]>([])
const [currentFileId, setCurrentFileId] = useState<string | null>(fileId)
const [compareData, setCompareData] = useState<ScheduleData | null>(null)
@@ -462,7 +499,9 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
})
}, [data.dayIndex])
const handleSave = useCallback(async () => {
const handleSave = useCallback(async (): Promise<boolean> => {
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}
/>
)}

View File

@@ -238,6 +238,7 @@ export function FileManager({ onOpenFile, onCompare, onCreateNew, onLogout }: Fi
<th className="px-4 py-3 w-8"></th>
<th className="px-4 py-3 text-left text-xs text-slate-400 uppercase tracking-wider">Název</th>
<th className="px-4 py-3 text-left text-xs text-slate-400 uppercase tracking-wider">Upraveno</th>
<th className="px-4 py-3 text-left text-xs text-slate-400 uppercase tracking-wider">Publikováno</th>
<th className="px-4 py-3 text-left text-xs text-slate-400 uppercase tracking-wider">Vytvořeno</th>
<th className="px-4 py-3 text-right text-xs text-slate-400 uppercase tracking-wider">Akce</th>
</tr>
@@ -282,6 +283,19 @@ export function FileManager({ onOpenFile, onCompare, onCreateNew, onLogout }: Fi
<td className="px-4 py-3">
<span className="text-sm text-slate-400">{formatDate(file.modifiedAt)}</span>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<span className="text-sm text-slate-400">{file.publishedAt ? formatDate(file.publishedAt) : '—'}</span>
{file.hasUnpublishedChanges && (
<span
title="Rozpracované změny, které uživatelé s náhledem zatím nevidí"
className="text-[10px] px-1.5 py-0.5 rounded bg-amber-700/40 text-amber-300 border border-amber-700/50"
>
nepublikované změny
</span>
)}
</div>
</td>
<td className="px-4 py-3">
<span className="text-sm text-slate-400">{formatDate(file.createdAt)}</span>
</td>

View File

@@ -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
</button>
{/* Auto-save state */}
<span
className={`text-[10px] whitespace-nowrap ${
saveStatus === 'error' ? 'text-red-400'
: dirty ? 'text-amber-400'
: 'text-slate-500'
}`}
title="Změny se ukládají automaticky několik sekund po poslední úpravě"
>
{saveStatus === 'error' ? 'Uložení selhalo, zkusím znovu'
: dirty ? 'Neuložené změny…'
: lastSavedAt ? `Uloženo ${formatStamp(lastSavedAt)}`
: ''}
</span>
{onPublish && (
<>
<div className="h-5 w-px bg-slate-700" />
<button
onClick={onPublish}
disabled={publishStatus === 'publishing'}
title={hasUnpublished
? 'Uložit a zveřejnit aktuální stav pro uživatele s náhledem'
: 'Náhled odpovídá aktuálnímu stavu'}
className={`px-3 py-1 rounded text-xs border cursor-pointer transition-colors
${publishStatus === 'published'
? 'bg-green-700/70 text-green-100 border-green-600'
: publishStatus === 'error'
? 'bg-red-700/70 text-red-100 border-red-600'
: publishStatus === 'publishing'
? 'bg-slate-700 text-slate-400 border-slate-600'
: hasUnpublished
? 'bg-amber-700/70 text-amber-100 border-amber-600 hover:bg-amber-600/70'
: 'bg-slate-800 text-slate-300 border-slate-700 hover:bg-slate-700'
}`}
>
{publishStatus === 'publishing' ? 'Publikuji...'
: publishStatus === 'published' ? 'Publikováno'
: publishStatus === 'error' ? 'Chyba!'
: 'Publikovat'}
</button>
<span
className={`text-[10px] whitespace-nowrap ${hasUnpublished ? 'text-amber-400' : 'text-slate-500'}`}
title={publishedAt ? `Naposledy publikováno ${formatStamp(publishedAt)}` : 'Zatím nepublikováno'}
>
{hasUnpublished
? '● Nepublikované změny'
: publishedAt ? `Publikováno ${formatStamp(publishedAt)}` : 'Nepublikováno'}
</span>
</>
)}
</>
)}
{isReadOnly && publishedAt && (
<span className="text-[10px] text-slate-500 whitespace-nowrap" title="Zobrazená verze plánu">
Publikováno {formatStamp(publishedAt)}
</span>
)}
{onExportPdf && activeMonth && (
<button
onClick={() => onExportPdf(activeMonth)}

View File

@@ -86,6 +86,8 @@ export interface ScheduleFile {
name: string
createdAt: string
modifiedAt: string
publishedAt?: string | null // when the viewer-visible snapshot was last published
hasUnpublishedChanges?: boolean // draft differs from the published snapshot
}
export interface ScheduleFileWithData extends ScheduleFile {