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

@@ -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}
/>
)}