Scope manual sync buttons to current view dates only

Sync rota and pull timesheets now operate on the 7-day view window rather
than rolling default windows — reduces API calls and allows backdating.
Cron job still uses rolling defaults via runRotaSync()/runTimesheetSync().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-22 20:08:43 +00:00
parent 9bfd14a082
commit ffefa1914f
9 changed files with 81 additions and 60 deletions

View file

@ -5,25 +5,37 @@ function fmtDate(d) {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
} }
export async function runRotaSync() { function defaultRotaWindow() {
const today = new Date()
const from = new Date(today); from.setDate(from.getDate() - 7)
const to = new Date(today); to.setDate(to.getDate() + 28)
return { from: fmtDate(from), to: fmtDate(to) }
}
function defaultTimesheetWindow() {
const today = new Date()
const from = new Date(today); from.setDate(from.getDate() - 7)
return { from: fmtDate(from), to: fmtDate(today) }
}
// from/to: 'YYYY-MM-DD' strings — defaults to rolling window if omitted
export async function runRotaSync(from, to) {
const deptIds = (await getConfig('workforce_departments', [])) || [] const deptIds = (await getConfig('workforce_departments', [])) || []
if (!deptIds.length) throw new Error('No HK departments selected — configure in Category Settings') if (!deptIds.length) throw new Error('No HK departments selected — configure in Category Settings')
const today = new Date() if (!from || !to) ({ from, to } = defaultRotaWindow())
const fromDate = new Date(today); fromDate.setDate(fromDate.getDate() - 7)
const toDate = new Date(today); toDate.setDate(toDate.getDate() + 28) const fromDate = new Date(from + 'T00:00:00')
const from = fmtDate(fromDate) const toDate = new Date(to + 'T00:00:00')
const to = fmtDate(toDate)
// API limit: max 7 days per request — split into weekly chunks // API limit: max 7 days per request — split into weekly chunks
const chunks = [] const chunks = []
const cur = new Date(fromDate) const cur = new Date(fromDate)
while (cur <= toDate) { while (cur <= toDate) {
const chunkFrom = fmtDate(cur) const chunkFrom = fmtDate(cur)
const chunkToDate = new Date(cur) const chunkEnd = new Date(cur); chunkEnd.setDate(chunkEnd.getDate() + 6)
chunkToDate.setDate(chunkToDate.getDate() + 6) if (chunkEnd > toDate) chunkEnd.setTime(toDate.getTime())
if (chunkToDate > toDate) chunkToDate.setTime(toDate.getTime()) chunks.push({ from: chunkFrom, to: fmtDate(chunkEnd) })
chunks.push({ from: chunkFrom, to: fmtDate(chunkToDate) })
cur.setDate(cur.getDate() + 7) cur.setDate(cur.getDate() + 7)
} }
@ -62,14 +74,15 @@ export async function runRotaSync() {
return { ok: true, from, to, dates_synced: dailyRows.length } return { ok: true, from, to, dates_synced: dailyRows.length }
} }
export async function runTimesheetSync(days = 7) { // from/to: 'YYYY-MM-DD' strings — defaults to last 7 days if omitted
export async function runTimesheetSync(from, to) {
const deptIds = (await getConfig('workforce_departments', [])) || [] const deptIds = (await getConfig('workforce_departments', [])) || []
if (!deptIds.length) throw new Error('No HK departments selected — configure in Category Settings') if (!deptIds.length) throw new Error('No HK departments selected — configure in Category Settings')
const today = new Date() if (!from || !to) ({ from, to } = defaultTimesheetWindow())
const fromDate = new Date(today); fromDate.setDate(fromDate.getDate() - days)
const from = fmtDate(fromDate) const fromDate = new Date(from + 'T00:00:00')
const to = fmtDate(today) const toDate = new Date(to + 'T00:00:00')
const staffList = await fetchTimesheetShifts(from, to, deptIds) const staffList = await fetchTimesheetShifts(from, to, deptIds)
@ -83,7 +96,7 @@ export async function runTimesheetSync(days = 7) {
const dailyRows = [] const dailyRows = []
const day = new Date(fromDate) const day = new Date(fromDate)
while (day <= today) { while (day <= toDate) {
const d = fmtDate(day) const d = fmtDate(day)
dailyRows.push({ date: d, staff: byDate[d] || [], source: 'timesheet' }) dailyRows.push({ date: d, staff: byDate[d] || [], source: 'timesheet' })
day.setDate(day.getDate() + 1) day.setDate(day.getDate() + 1)

View file

@ -23,19 +23,21 @@ export async function workforceRoutes(app) {
// ── POST /api/workforce/sync — rolling rota window: today-7 → today+28 ────── // ── POST /api/workforce/sync — rolling rota window: today-7 → today+28 ──────
app.post('/api/workforce/sync', { preHandler: requireCap('planner') }, async (req, reply) => { app.post('/api/workforce/sync', { preHandler: requireCap('planner') }, async (req, reply) => {
const { start, end } = req.query
try { try {
return await runRotaSync() return await runRotaSync(start, end)
} catch (err) { } catch (err) {
req.log.error({ err: err.message }, 'rota sync failed') req.log.error({ err: err.message }, 'rota sync failed')
return reply.status(errStatus(err.message)).send({ error: err.message }) return reply.status(errStatus(err.message)).send({ error: err.message })
} }
}) })
// ── POST /api/workforce/sync-timesheets — last 7 days of actual hours ──────── // ── POST /api/workforce/sync-timesheets?start=&end= ──────────────────────────
app.post('/api/workforce/sync-timesheets', { preHandler: requireCap('planner') }, async (req, reply) => { app.post('/api/workforce/sync-timesheets', { preHandler: requireCap('planner') }, async (req, reply) => {
const { start, end } = req.query
try { try {
return await runTimesheetSync() return await runTimesheetSync(start, end)
} catch (err) { } catch (err) {
req.log.error({ err: err.message }, 'timesheet sync failed') req.log.error({ err: err.message }, 'timesheet sync failed')
return reply.status(errStatus(err.message)).send({ error: err.message }) return reply.status(errStatus(err.message)).send({ error: err.message })

File diff suppressed because one or more lines are too long

View file

@ -5,7 +5,7 @@
<meta name="viewport" content="width=1280" /> <meta name="viewport" content="width=1280" />
<meta name="theme-color" content="#2d6a4f" /> <meta name="theme-color" content="#2d6a4f" />
<title>HK Planner</title> <title>HK Planner</title>
<script type="module" crossorigin src="/hk-planner/assets/index-C9TVzZ7i.js"></script> <script type="module" crossorigin src="/hk-planner/assets/index-Bp4tnr99.js"></script>
<link rel="stylesheet" crossorigin href="/hk-planner/assets/index-B7_UXJgZ.css"> <link rel="stylesheet" crossorigin href="/hk-planner/assets/index-B7_UXJgZ.css">
<link rel="manifest" href="/hk-planner/manifest.webmanifest"><script id="vite-plugin-pwa:register-sw" src="/hk-planner/registerSW.js"></script></head> <link rel="manifest" href="/hk-planner/manifest.webmanifest"><script id="vite-plugin-pwa:register-sw" src="/hk-planner/registerSW.js"></script></head>
<body> <body>

2
frontend/dist/sw.js vendored
View file

@ -1 +1 @@
if(!self.define){let e,i={};const n=(n,s)=>(n=new URL(n+".js",s).href,i[n]||new Promise(i=>{if("document"in self){const e=document.createElement("script");e.src=n,e.onload=i,document.head.appendChild(e)}else e=n,importScripts(n),i()}).then(()=>{let e=i[n];if(!e)throw new Error(`Module ${n} didnt register its module`);return e}));self.define=(s,r)=>{const o=e||("document"in self?document.currentScript.src:"")||location.href;if(i[o])return;let t={};const c=e=>n(e,o),d={module:{uri:o},exports:t,require:c};i[o]=Promise.all(s.map(e=>d[e]||c(e))).then(e=>(r(...e),t))}}define(["./workbox-9c191d2f"],function(e){"use strict";self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"registerSW.js",revision:"a2c395d8c225f1b3ea12388f15189bce"},{url:"index.html",revision:"3b1c49874f107c5a3d6697cd6a89320e"},{url:"icons/icon-512.png",revision:"c32202b9deed67ef38331f63dec9d1c8"},{url:"icons/icon-192.png",revision:"46ece317d50d10b8f5e225e073b3221d"},{url:"assets/index-C9TVzZ7i.js",revision:null},{url:"assets/index-B7_UXJgZ.css",revision:null},{url:"manifest.webmanifest",revision:"c2510de876adb84db0c4b300b71216fa"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("/hk-planner/index.html"),{denylist:[/\/api\//]}))}); if(!self.define){let e,i={};const n=(n,s)=>(n=new URL(n+".js",s).href,i[n]||new Promise(i=>{if("document"in self){const e=document.createElement("script");e.src=n,e.onload=i,document.head.appendChild(e)}else e=n,importScripts(n),i()}).then(()=>{let e=i[n];if(!e)throw new Error(`Module ${n} didnt register its module`);return e}));self.define=(s,r)=>{const t=e||("document"in self?document.currentScript.src:"")||location.href;if(i[t])return;let o={};const c=e=>n(e,t),d={module:{uri:t},exports:o,require:c};i[t]=Promise.all(s.map(e=>d[e]||c(e))).then(e=>(r(...e),o))}}define(["./workbox-9c191d2f"],function(e){"use strict";self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"registerSW.js",revision:"a2c395d8c225f1b3ea12388f15189bce"},{url:"index.html",revision:"455924148ee524d7363268ccabc71ea8"},{url:"icons/icon-512.png",revision:"c32202b9deed67ef38331f63dec9d1c8"},{url:"icons/icon-192.png",revision:"46ece317d50d10b8f5e225e073b3221d"},{url:"assets/index-Bp4tnr99.js",revision:null},{url:"assets/index-B7_UXJgZ.css",revision:null},{url:"manifest.webmanifest",revision:"c2510de876adb84db0c4b300b71216fa"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("/hk-planner/index.html"),{denylist:[/\/api\//]}))});

View file

@ -83,11 +83,11 @@ export function putWorkforceDepartments(dept_ids) {
body: JSON.stringify({ dept_ids }), body: JSON.stringify({ dept_ids }),
}); });
} }
export function syncWorkforce() { export function syncWorkforce(start, end) {
return request('/workforce/sync', { method: 'POST' }); return request(`/workforce/sync?start=${start}&end=${end}`, { method: 'POST' });
} }
export function syncTimesheets() { export function syncTimesheets(start, end) {
return request('/workforce/sync-timesheets', { method: 'POST' }); return request(`/workforce/sync-timesheets?start=${start}&end=${end}`, { method: 'POST' });
} }
export function getWorkforceShifts(start, end) { export function getWorkforceShifts(start, end) {
return request(`/workforce/shifts?start=${start}&end=${end}`); return request(`/workforce/shifts?start=${start}&end=${end}`);

View file

@ -116,12 +116,12 @@ export function putWorkforceDepartments(dept_ids: string[]): Promise<{ ok: boole
}) })
} }
export function syncWorkforce(): Promise<{ ok: boolean; from: string; to: string; dates_synced: number }> { export function syncWorkforce(start: string, end: string): Promise<{ ok: boolean; from: string; to: string; dates_synced: number }> {
return request('/workforce/sync', { method: 'POST' }) return request(`/workforce/sync?start=${start}&end=${end}`, { method: 'POST' })
} }
export function syncTimesheets(): Promise<{ ok: boolean; from: string; to: string; dates_synced: number }> { export function syncTimesheets(start: string, end: string): Promise<{ ok: boolean; from: string; to: string; dates_synced: number }> {
return request('/workforce/sync-timesheets', { method: 'POST' }) return request(`/workforce/sync-timesheets?start=${start}&end=${end}`, { method: 'POST' })
} }
export function getWorkforceShifts(start: string, end: string): Promise<Record<string, WfDayData>> { export function getWorkforceShifts(start: string, end: string): Promise<Record<string, WfDayData>> {

View file

@ -250,13 +250,15 @@ export function Planner() {
} }
// ── Workforce sync ─────────────────────────────────────────────────────────── // ── Workforce sync ───────────────────────────────────────────────────────────
async function syncRota() { async function syncRota() {
if (!bookings)
return;
const start = bookings.dates[0];
const end = bookings.dates[bookings.dates.length - 1];
setSyncing(true); setSyncing(true);
try { try {
await syncWorkforce(); await syncWorkforce(start, end);
if (bookings) { const shifts = await getWorkforceShifts(start, end);
const shifts = await getWorkforceShifts(bookings.dates[0], bookings.dates[bookings.dates.length - 1]);
setWfShifts(shifts); setWfShifts(shifts);
}
flash('Rota synced from Workforce'); flash('Rota synced from Workforce');
} }
catch (e) { catch (e) {
@ -267,13 +269,15 @@ export function Planner() {
} }
} }
async function syncTimesheetData() { async function syncTimesheetData() {
if (!bookings)
return;
const start = bookings.dates[0];
const end = bookings.dates[bookings.dates.length - 1];
setTimesheetSyncing(true); setTimesheetSyncing(true);
try { try {
await syncTimesheets(); await syncTimesheets(start, end);
if (bookings) { const shifts = await getWorkforceShifts(start, end);
const shifts = await getWorkforceShifts(bookings.dates[0], bookings.dates[bookings.dates.length - 1]);
setWfShifts(shifts); setWfShifts(shifts);
}
flash('Timesheets pulled from Workforce'); flash('Timesheets pulled from Workforce');
} }
catch (e) { catch (e) {

View file

@ -298,13 +298,14 @@ export function Planner() {
// ── Workforce sync ─────────────────────────────────────────────────────────── // ── Workforce sync ───────────────────────────────────────────────────────────
async function syncRota() { async function syncRota() {
if (!bookings) return
const start = bookings.dates[0]
const end = bookings.dates[bookings.dates.length - 1]
setSyncing(true) setSyncing(true)
try { try {
await syncWorkforce() await syncWorkforce(start, end)
if (bookings) { const shifts = await getWorkforceShifts(start, end)
const shifts = await getWorkforceShifts(bookings.dates[0], bookings.dates[bookings.dates.length - 1])
setWfShifts(shifts) setWfShifts(shifts)
}
flash('Rota synced from Workforce') flash('Rota synced from Workforce')
} catch (e) { } catch (e) {
flash(e instanceof Error ? e.message : 'Sync failed', true) flash(e instanceof Error ? e.message : 'Sync failed', true)
@ -314,13 +315,14 @@ export function Planner() {
} }
async function syncTimesheetData() { async function syncTimesheetData() {
if (!bookings) return
const start = bookings.dates[0]
const end = bookings.dates[bookings.dates.length - 1]
setTimesheetSyncing(true) setTimesheetSyncing(true)
try { try {
await syncTimesheets() await syncTimesheets(start, end)
if (bookings) { const shifts = await getWorkforceShifts(start, end)
const shifts = await getWorkforceShifts(bookings.dates[0], bookings.dates[bookings.dates.length - 1])
setWfShifts(shifts) setWfShifts(shifts)
}
flash('Timesheets pulled from Workforce') flash('Timesheets pulled from Workforce')
} catch (e) { } catch (e) {
flash(e instanceof Error ? e.message : 'Timesheet sync failed', true) flash(e instanceof Error ? e.message : 'Timesheet sync failed', true)