Rework workforce sync to per-date storage with rolling window

Replaces the single workforce_rota config snapshot (which was overwritten
on each sync, losing data when switching weeks) with a workforce_daily_shifts
table keyed by date. Sync now covers a rolling today-7 to today+28 window
unconditionally — no date params needed. Each date's record carries a
synced_at timestamp so the UI shows the age of the oldest date in view.
Mid-week viewing works naturally since data is stored per date not per week.
source column reserved for future timesheet replacement of past dates.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-22 14:06:56 +00:00
parent 1a8d8d9a2b
commit c7066521ff
10 changed files with 278 additions and 178 deletions

View file

@ -10,6 +10,14 @@ export async function initDb() {
value JSONB NOT NULL DEFAULT 'null'::jsonb
)
`)
await pool.query(`
CREATE TABLE IF NOT EXISTS workforce_daily_shifts (
date TEXT PRIMARY KEY,
synced_at TIMESTAMPTZ NOT NULL DEFAULT now(),
source TEXT NOT NULL DEFAULT 'workforce',
staff JSONB NOT NULL DEFAULT '[]'::jsonb
)
`)
}
export async function getConfig(key, defaultVal = null) {
@ -24,3 +32,38 @@ export async function setConfig(key, value) {
[key, JSON.stringify(value)]
)
}
export async function getWorkforceShifts(start, end) {
const { rows } = await pool.query(
`SELECT date, synced_at, source, staff FROM workforce_daily_shifts
WHERE date >= $1 AND date <= $2 ORDER BY date`,
[start, end]
)
const result = {}
for (const row of rows) {
result[row.date] = { synced_at: row.synced_at, source: row.source, staff: row.staff }
}
return result
}
export async function upsertWorkforceShifts(dailyRows) {
if (!dailyRows.length) return
const client = await pool.connect()
try {
await client.query('BEGIN')
for (const row of dailyRows) {
await client.query(
`INSERT INTO workforce_daily_shifts (date, synced_at, source, staff)
VALUES ($1, now(), 'workforce', $2::jsonb)
ON CONFLICT (date) DO UPDATE SET synced_at = now(), staff = EXCLUDED.staff`,
[row.date, JSON.stringify(row.staff)]
)
}
await client.query('COMMIT')
} catch (err) {
await client.query('ROLLBACK')
throw err
} finally {
client.release()
}
}

View file

@ -10,7 +10,7 @@ export async function configRoutes(app) {
app.get('/api/config', async (req) => {
const [
timeReqs, staffData, pickupData, generalTasks, lastReviewed,
workforceRota, workforceDepts, adjustments,
workforceDepts, adjustments,
warnOverRed, warnOverAmber, warnUnderAmber, warnUnderRed,
] = await Promise.all([
getConfig('time_requirements', {}),
@ -18,7 +18,6 @@ export async function configRoutes(app) {
getConfig('pickup_data', {}),
getConfig('general_tasks', []),
getConfig('last_reviewed', null),
getConfig('workforce_rota', null),
getConfig('workforce_departments', []),
getConfig('adjustments', []),
getConfig('warn_over_red_hrs', 4),
@ -39,7 +38,6 @@ export async function configRoutes(app) {
pickup_data: pickupData || {},
general_tasks: generalTasks || [],
last_reviewed: lastReviewed || yestStr,
workforce_rota: workforceRota || null,
workforce_departments: workforceDepts || [],
adjustments: adjustments || [],
warn_over_red_hrs: warnOverRed != null ? warnOverRed : 4,

View file

@ -1,11 +1,15 @@
import { requireAuth, requireCap } from '../auth.js'
import { getConfig, setConfig } from '../db.js'
import { getConfig, setConfig, getWorkforceShifts, upsertWorkforceShifts } from '../db.js'
import { fetchDepartments, fetchStaff, fetchShifts } from '../lib/workforce.js'
function fmtDate(d) {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
export async function workforceRoutes(app) {
app.addHook('preHandler', requireAuth)
// ── GET /api/workforce/departments — list depts for this location ────────────
// ── GET /api/workforce/departments ───────────────────────────────────────────
app.get('/api/workforce/departments', { preHandler: requireCap('settings') }, async (req, reply) => {
try {
@ -16,29 +20,58 @@ export async function workforceRoutes(app) {
}
})
// ── POST /api/workforce/sync?start=&end= ─────────────────────────────────────
// ── POST /api/workforce/sync — rolling window: today-7 → today+28 ───────────
app.post('/api/workforce/sync', { preHandler: requireCap('planner') }, async (req, reply) => {
const { start, end } = req.query
if (!start || !end) return reply.status(400).send({ error: 'start and end query params required' })
const deptIds = (await getConfig('workforce_departments', [])) || []
if (!deptIds.length) {
return reply.status(400).send({ error: 'No HK departments selected — configure in Category Settings' })
}
const today = new Date()
const fromDate = new Date(today); fromDate.setDate(fromDate.getDate() - 7)
const toDate = new Date(today); toDate.setDate(toDate.getDate() + 28)
const from = fmtDate(fromDate)
const to = fmtDate(toDate)
try {
const staff = await fetchShifts(start, end, deptIds)
const snapshot = { last_sync: new Date().toISOString(), dates: [start, end], staff }
await setConfig('workforce_rota', snapshot)
return snapshot
const staffList = await fetchShifts(from, to, deptIds)
// Pivot per-member → per-date
const byDate = {}
for (const member of staffList) {
for (const [date, shift] of Object.entries(member.days)) {
if (!byDate[date]) byDate[date] = []
byDate[date].push({ id: member.id, name: member.name, hours: shift.hours, times: shift.times })
}
}
// Build a row for every date in the window (empty staff = no one scheduled)
const dailyRows = []
const cur = new Date(fromDate)
while (cur <= toDate) {
const d = fmtDate(cur)
dailyRows.push({ date: d, staff: byDate[d] || [] })
cur.setDate(cur.getDate() + 1)
}
await upsertWorkforceShifts(dailyRows)
return { ok: true, from, to, dates_synced: dailyRows.length }
} catch (err) {
const status = err.message.includes('not configured') ? 503 : 502
return reply.status(status).send({ error: err.message })
}
})
// ── GET /api/workforce/staff — staff list for manual row datalist ─────────────
// ── GET /api/workforce/shifts?start=&end= ────────────────────────────────────
app.get('/api/workforce/shifts', { preHandler: requireCap('planner') }, async (req, reply) => {
const { start, end } = req.query
if (!start || !end) return reply.status(400).send({ error: 'start and end query params required' })
return getWorkforceShifts(start, end)
})
// ── GET /api/workforce/staff — cached staff list for manual row datalist ─────
app.get('/api/workforce/staff', { preHandler: requireCap('planner') }, async (req, reply) => {
const deptIds = (await getConfig('workforce_departments', [])) || []