Two bugs inflating the rota variance figures in AI insights:
1. Actual cost (incl. NI oncosts) was compared against rota cost that
silently falls back to base pay — Workforce's schedules API never
actually supplies oncosts (confirmed: published_total_cost equals
published_base_cost on every synced row), so every variance was
inflated by ~14-20% for reasons unrelated to real overspend. Now
always compares base cost on both sides, with a note in the prompt
that this is a base-pay-only comparison.
2. Variance was summed over the full window using actual cost, but
only over covered days using rota cost, understating rota further
whenever coverage was incomplete. Now restricts the actual-cost side
to the same days rota data exists for, and reports coverage (days
w/ rota vs total) explicitly so partial coverage isn't presented as
a confirmed figure.
Also: the employee anomalies section only had department_id (a raw
Workforce code), not department_name, since wage_actuals_detail doesn't
store it — resolved via wage_actuals so the model can say "Chef"
instead of guessing from a numeric code.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Dashboard now shows a collapsible history of past AI insights (reusing
the existing /history endpoint), and generation now includes the last
3 insights in the prompt so the model can reference what it already
said instead of repeating the same points every day.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
runRollingSync only ever syncs wage_scheduled 14 days forward, so the AI
insight's 28-day rota-vs-actual comparison had no historical rota to compare
against for a freshly-scaffolded app. Adds a dedicated backfill action that
pulls past schedules via the same throttled weekly-batch pattern as the
existing actuals backfill.
AI Insights: a daily Claude-generated wage cost briefing covering
month-to-date pace vs budget, prior-month/prior-year comparison,
rota-vs-actual variance by department, a rota-informed forecast to
month-end, employee-level anomalies, and wage cost as a % of revenue.
Runs on a configurable daily schedule or on demand, gated by a manual
5-minute rate limit and a daily token budget. Uses the Anthropic key
configured centrally in Portal → Settings → Integrations.
Also includes the rota-vs-repeat-pattern forecast method (published/
draft rota tiers with same-weekday fallback) already built into the
Weekly/Monthly views, and adds a .gitignore for node_modules/dist.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
syncActuals() recomputes the full date range from Workforce on every run
but only ever upserted rows, so a shift that moved department or was
edited/deleted since the last sync left its old wage_actuals_detail row
in place — showing a "ghost" employee in the dept breakdown whose cost
no longer matched the (correctly overwritten) department total.
Now deletes existing wage_actuals/wage_actuals_detail rows for the
synced date range before reinserting, so removed/reassigned shifts
don't linger.
The forecasting API's total.otb is booked-to-date only; total.forecast adds
expected pickup and equals otb for past dates anyway. Using otb-only for
future days understated expected net sales in the current-week/month
forecast sections. Relabelled "(OTB)" cards to "(Forecast)" to match.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The shifts endpoint omits employer NI from cost_with_oncosts for some employees.
The timesheets/on/{date}?include_oncosts=true endpoint includes NI, matching
WF's native 'Cost by Location and Team' Timesheet exc. leave figure exactly.
June 2026 verification: B.shiftLevel.oncostTotal = £51,005.72 = WF target.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fetches all active users with salary field set whose report_department_id
is in a No 4 department. Reports raw salary field shape, prorated period
cost, and combined (shifts oncostExLeave + prorated salary) to test whether
salaried staff account for the gap vs WF native report.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Without this parameter, nested shifts[] only return shift.cost with no
cost_with_oncosts field. The FD's PBI uses show_costs=true on timesheets
and may also require include_oncosts=true to get employer oncost fields.
This is the missing parameter that may explain the B.oncostTotal == baseCost issue.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The timesheets/on/{date} response has total_cost at the timesheet level,
not cost or cost_with_oncosts. Previous code was reading undefined and
summing zeros.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
B now also sums cost/cost_with_oncosts at the timesheet parent object level (not just
nested shifts[]) — reveals whether PBI is using a timesheet-level field we're missing.
compareEndpoints now returns:
- A (existing): shifts API + report_location_id + client dept filter by location_id
- A2 (new): same shifts, client filter broadened to location_id OR report_location_id
- A3 (new): same shifts, no client-side dept filter (trust API filter only)
- extra_depts_via_report_location_id: departments in A2 but not A
- B: per-day timesheets with both shiftLevel and timesheetLevel cost sums
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The timesheets/on/{date} endpoint nested shifts don't return cost_with_oncosts
— oncostTotal == baseCost in all results. The shifts endpoint does return it.
Hypothesis: shifts endpoint, leave filtered, cost_with_oncosts ≈ £51k:
- A (shifts, report_location_id, all): £47,607 (shift.cost, includes leave)
- B (timesheets, dept filter, ex-leave): £40,845 (shift.cost only, no oncosts)
- Estimated A_ex_leave × cost_with_oncosts: ~£44.7k × 1.14 ≈ £51,005 ✓
syncActuals reverted to shifts endpoint, adds leave_request_id filter,
uses cost_with_oncosts as total_cost (was using shift.cost before).
compareEndpoints A now shows both baseCostExLeave and oncostExLeave so
we can confirm the £51k match directly.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Key findings from diagnostic runs:
- timesheets/on/{date} returns only that DATE's shifts (not the whole
week), so weekly-step fetch only samples 6 days of a 30-day month
- user.report_department_id filter too strict: only 32 staff have it
set, missing many valid No 4 employees → £15k instead of £51k
- Correct approach: per-day fetch (all 30 days), dedup by shift.id
(robust against any duplicate timesheet records), filter by
shift.department_id → location_id (matches WF "by Location and Team")
Also includes inactive users (show_inactive=true) to catch staff who
left mid-month but still have timesheets in the date range.
Compare endpoint now shows baseCost (shift.cost) and oncostTotal
(shift.cost_with_oncosts) so we can confirm which matches WF's £51k.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1. Weekly fetch step (not per-day): timesheets/on/{date} returns the
whole weekly period for any date in that week, so fetching 30 days
counted each weekly timesheet ~7x. Now fetches one date per 7-day
step from 'to' backwards (PBI pattern), deduplicates by timesheet.id,
and filters shifts by sh.date to the requested window.
2. Filter by user.report_department_id not shift.department_id: shared
departments (HR, Management, etc.) have a single location assignment
(No 4) but staff from all hotels clock into them. Filtering by the
employee's HOME department (report_department_id) correctly isolates
No 4 staff. Mirrors the PBI model join: timesheet → user →
report_department_id → department.location_id → hotel.
Both fixes applied to syncActuals and compareEndpoints diagnostic.
Target: B.cost = WF "Timesheet exc. leave inc. allowances" (~£51k June).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Uses /api/v2/timesheets/on/{date} per day (no location param), expands
nested shifts[], skips leave (leave_request_id!=null), and post-filters
to this hotel's departments via location_id match on the departments
list. This mirrors exactly what the FD's Power BI query does and should
give the correct £51k timesheet figure for June.
Also simplifies compareEndpoints to two variants: A (old shifts method)
vs B (new timesheets method) so the discrepancy is immediately visible.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Timesheet per-day fetch returns 3,269 records with £0 — cost fields are
named differently. Adds tsBaseCost()/tsTotalCost() helpers that try multiple
field names (cost, timesheet_cost, base_cost, employee_cost, etc.). Adds
sampleFields to E result so we can see the actual field names on a live
record. Also samples a shift record (variant A) for confirmation.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sequential day-by-day caused 504 on month-long ranges. Switching to
Promise.all over all dates so 30 concurrent calls complete in ~3-5s.
Route timeout bumped to 120s to give the parallel calls room to land.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds four endpoint variants (A-D) to isolate the location filter discrepancy:
report_location_id vs location_id vs no filter vs timesheets range. If the
timesheets range 404s, auto-falls back to per-day /timesheets/on/{date} fetch
(variant E) so we can see timesheet totals regardless of API version.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Rota forecasting: only trust Workforce rota for the current Mon–Sun week —
beyond that use prior-week actuals to avoid incomplete next-week rotas
deflating the EOM forecast.
% vs Budget cards (Weekly + Monthly): show as coloured headline number
(green/amber/red) instead of small pct-badge chip, matching other cards.
Forecast EOM: PY sub-label shows full prior-year month total + % delta,
replacing the static "rota + prior-week actual" note once PY data exists.
Achieves this by fetching the full PY month rather than MTD-capped range,
then deriving both MTD (for Actual MTD card) and full-month (for EOM card).
Backfill depth: 13 → 25 months so past months in Rolling 12 have PY wage
data available (viewing Dec 2024 needs Dec 2023 = 19 months ago).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Stores per-employee shift costs in wage_actuals_detail (APPROVED shifts
only). Sync fetches employee name map from /api/v2/users alongside dept
names. Clicking any dept row in Weekly or Monthly opens a modal showing
Employee · Shifts · Cost · % of Dept, fetched on demand.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Schedules endpoint has same 7-day limit as shifts. The 14-day forward
window was being fetched in one call — split into two 7-day batches.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Full wage cost reporting app — weekly/monthly views, rolling 12-week/12-month
history, budget management, Workforce API sync with SSE backfill, net sales
via forecasting public API, department filter, CSV export.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>