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:
103
web/server.js
103
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 {
|
||||
|
||||
Reference in New Issue
Block a user