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 {