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:
123
web/src/App.tsx
123
web/src/App.tsx
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user