Dashboard's week/month tiles were only computed on initial load; the 5-minute
timer refreshed the AI insight but not the tiles, so an installed PWA left
open would keep showing stale forecast numbers indefinitely. Monthly's forecast
had no refresh at all. Both now recompute every 5 minutes while viewing the
current month.
iOS Safari has no beforeinstallprompt API, so the portal's Install
button (?install=1) silently did nothing there. IosInstallHint now
shows Share -> Add to Home Screen steps on Safari, or a prompt to
switch to Safari first if opened in another iOS browser/in-app
webview (those can't install PWAs on iOS at all).
Also added apple-mobile-web-app-title + apple-touch-icon so the
home screen icon isn't a page screenshot, and added the
beforeinstallprompt handler that was missing entirely (Android
Install button did nothing either).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The "Prev wk" figure shown under Forecast Full Week was reusing
prevCost, which is deliberately scoped to "same elapsed days as WTD"
(e.g. only Mon-Wed of last week if today's Wednesday) — correct for
the per-department table's WTD-vs-WTD "vs Prev Wk" column, but wrong
paired with totalForecast, which projects a full 7 days. Comparing a
full-week projection against a few days of last week made prev week
look artificially low and the delta look inflated.
Added prevFullCost (full Mon-Sun of the prior week — already present
in the 14-day actuals fetch) for the full-week comparison specifically;
left prevCost/totalPrev untouched for the table's WTD comparisons.
Monthly.tsx and Dashboard.tsx only ever fetched actuals for the current
period (month-start..month-end, or a 7-day lookback for the week view),
but forecastDayCost's same-weekday fallback hops back up to 42 days
looking for a match. For any early-period date whose hop landed before
the fetch window started, that data was never in memory at all — it
silently fell through to the 'none' tier (£0) once published rota ran
out, rather than finding a real historical match.
ai-insights.js's gatherForecastData already fetched a proper 42-day
lookback; these two pages didn't. Widened both fetches to match, with
explicit >= period-start filters on the actual-sum calculations so the
extra history is only ever used by the hop fallback, never counted
twice into "actual".
wage_actuals rows for the current day exist with real shift_count but
£0 cost until Workforce finishes costing the shift (clock-out/approval
pending). forecastDayCost was treating that as a legitimate zero-cost
actual, both zeroing today's own forecast contribution and poisoning
every future date whose 7-day hop-back chain lands on today — e.g.
every remaining same-weekday this month resolved straight to £0
instead of falling through to published rota or older actuals.
Manifest referenced icon-192.png/icon-512.png but the icons directory
didn't exist, so Chrome failed installability and only offered a
home-screen shortcut instead of a full installable PWA.
Previously showed a false "all departments within budget" message even
when Settings has no per-department split defined, since every share
was 0/undefined and nothing was actually being evaluated.
Dashboard now shows Actual/Forecast, % vs budget, and % net sales for both
the current week and current month, plus a budget-watch callout listing any
departments forecast to finish the month over their budget share.
Split the system prompt into an editable part (persona/focus/tone/
format — what the briefing prioritises and how strict its length is)
and a fixed structural appendix (how the Manual Context and Recent
Previous Insights sections get interpreted, which map directly to
conditional data the code assembles, not just prose — kept safe from
being accidentally edited away).
New ai_insights_prompt_instructions config key, a
GET /api/ai-insights/default-prompt endpoint so the Settings UI can
pre-fill/reset to the built-in default, and a textarea in the AI
Insights settings card.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two real problems from live testing:
1. gatherEmployeeAnomalies() ranked by absolute cost, so it always
surfaced senior/supervisory/longer-shift staff — that's their
normal rate, not an anomaly. Now compares each employee's £/shift
this week against their own trailing 4-week average and only
reports the deviation; the prompt explicitly tells the model not to
flag high pay in absolute or relative terms, only genuine
deviations from someone's own baseline. Validated: Jack Evans
(previously flagged 3 briefings running) is +0.5% vs his own
baseline — not an anomaly at all — while Joseph Trice-Rolph's +41%
swing is a genuine standout.
2. Output was hitting the token cap and cutting off mid-sentence
(confirmed: last generation used exactly 900/900 output tokens).
Raised cap to 1400, and tightened the system prompt to a hard
5-bullet-total limit with no per-department/per-employee sections,
since the model was writing a full structured report instead of a
short briefing regardless of the token budget.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The Rota vs Actual section's caveat was already fixed, but the
Forecast section had its own separate "rota excludes NI oncosts" note
that referenced the same now-defunct /api/v2/schedules limitation —
confirmed the forward window genuinely carries real oncosts post
roster-migration (e.g. FOH 25/07: £562.75 base vs £641.93 w/ oncosts),
so this was actively telling the model to understate its own forecast
confidence for no reason.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Now that the sync uses /api/v2/rosters/on/{date} (2026-07-25) instead
of /api/v2/schedules, real employer-oncost figures are available for
rota data, so gatherRotaVsActualData() no longer needs the base-cost-
only workaround — it respects show_oncosts like the rest of the app
again. Also removes the now-inaccurate Monthly.tsx footnote claiming
rota-based forecasts exclude NI, and the now-obsolete methodology note
that told the model to expect a swing from the base-cost fix (moot —
insight history is being cleared, so there's nothing to compare
against).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Free-text setting fed directly into every briefing's prompt as ground
truth (e.g. "Joseph Trice-Rolph is on a flexi rota, has no fixed rota
shifts — account for that in overspend"), so known context the data
itself can't show doesn't get flagged as an anomaly. New
ai_insights_manual_context config key, a textarea in Settings, and a
top-of-prompt section in the job with an explicit instruction to treat
it as ground truth.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The schedules list endpoint has two confirmed problems: it silently
drops all shift data for deactivated/terminated employees (even for
historical dates while they were still active), and it never returns
real oncost figures (cost_with_oncosts always equals cost). Both
verified against the live API. The roster endpoint (a different data
path — Roster is the weekly container, Schedule is an individual shift
within it) has neither problem: leavers' historical shifts are intact,
and real cost_with_oncosts + an oncosts_breakdown are present.
syncScheduled() now fetches one whole Mon-Sun roster per distinct week
overlapping the requested range (rosters/on/{date} returns the entire
week regardless of which date you ask for), flattens the nested
day->shifts structure, and filters back down to the requested range.
Explicitly paginated (page_size=100, following meta.total_pages)
rather than relying on the undocumented behaviour that omitting
page/page_size returns everything unpaginated.
Validated on dev before deploying: re-synced the 27/06-24/07 window
and confirmed a known leaver's shifts reappeared, real oncosts are now
present (Chef: £11,898 base vs £13,572 with oncosts), and forward/
draft-shift handling still works correctly for the 14-day rolling
sync. The Chef "overspend" the AI insight flagged turned out to be
entirely this artifact — corrected comparison shows a small underspend.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
MAX_OUTPUT_TOKENS was 600 and briefings were hitting it mid-sentence
(confirmed on a live generation — cut off mid-bullet). Raised to 900.
Also adds a time-boxed methodology note (expires 29/07/2026) so the
model doesn't flag the 25/07 base-cost-only rota fix as an unexplained
swing when it sees a prior insight's rota variance figures differ —
it was told to compare against recent insights explicitly, and without
this note it correctly but unhelpfully treated a deliberate correction
as an open question needing clarification.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
Same fix as maintenance/reports/room-planner: manifest scope was the
app's own base path instead of "/", and AuthGate unconditionally
hard-navigated window.top to the central /login on session expiry
even when not embedded in the portal iframe — together these dropped
an installed/standalone wages PWA into the portal's framed browser
view instead of staying in its own window.
AuthGate now only bounces to central login when actually embedded
(passing ?from= so it returns here afterwards); standalone or
directly-opened tabs get an in-app login form and never navigate
away. Also wired up the previously-dead inactivity auto-logout timer.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sidebar was missing the account info + sign-out control that every
other app's sidebar has; it showed a static hotel-name label instead.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Legend was sorted to mirror bar stack order, making it hard to tell
segments apart at a glance; reverse it. Tooltip also rendered behind
the legend div when they overlapped — raise its z-index above it.
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.
Rolling12Months: current month's budget wasn't prorated at all, so
%Budget compared MTD actual against the FULL monthly budget — always
looked artificially favorable. Now prorates by elapsed PY DOW-matched
sales share (flat day-count fallback), reusing already-fetched data.
Rolling12Weeks: budget was prorated, but the elapsed-day count mixed a
real timestamp with Math.ceil(...)+1, always overstating elapsed days
by one and inflating the budget denominator. Rewrote to sum per-day
budgets by date string (no fractional-time bug), and applied the same
PY DOW-weighting as the Weekly page, sourcing each day from its own
calendar month so weeks spanning a month boundary split correctly.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Distributes the monthly wage budget across days proportionally to each
day's share of last year's same-day-of-week sales, so pacing on Weekly/
Monthly views reflects real demand shape (e.g. weekend-heavy) rather than
an even calendar split. Falls back to the old flat day-count split when
PY sales data is unavailable. Weekly also now sources each day's budget
from its own calendar month, so weeks spanning a month boundary split
correctly across both months' budgets.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
Both views already showed % Net Sales with a PY % sub-line, but lacked a
raw-£ Net Sales card with PY value + delta% — the style already used for
past weeks/months. Adds it consistently across current-period, forecast,
and past-period sections in both views.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fixes an edge case where days more than 7 days past the actual cutoff had no
valid prior-week source day (itself a forecast day), silently contributing
£0. Now walks back in 7-day steps until landing on an actual day, so the
last complete actual week tiles forward for the rest of the month.
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>
Monthly: fetches previous month actuals; adds % delta vs prior month full
alongside existing vs PY column in the dept breakdown table.
Weekly: expanded 14-day actuals fetch (prev+current week) so prior-week
per-dept data is available for both forecast and comparison. Two-row summary
cards for current week (WTD actuals + full-week forecast). Past weeks keep
single-row layout. Dept table gains vs PY and vs Prev Wk delta columns;
budget columns use WTD pro-rata for current week, 7-day proportion for past.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Shows (actual_mtd - PY_MTD) / PY_MTD for current month rows,
(actual - PY_full) / PY_full for past months. Red if wages up vs PY,
green if down. Total row uses same basis. Matches department by ID
against pyDepts so departments with no PY data show —.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Both Weekly and Monthly now cut off actuals at yesterday (not today) to avoid
partial clockins and open timesheets skewing the WTD/MTD figures and % net sales.
PY comparison periods also cap at the equivalent yesterday for fair comparison.
Monthly rota forecast removed — all future days use prior-week same-day actuals
from yesterday back, which is more reliable than partially-published future rotas.
Monthly summary cards split into two rows for current month:
Row 1 (MTD to yesterday): Actual MTD · Budget pro-rata · % Budget MTD · % Net Sales MTD
Row 2 (Full month): Forecast EOM · Monthly Budget · % Budget (Forecast) · % Net Sales (OTB)
Net sales fetched for full month so row 2 % net sales uses OTB forecast revenue.
Past months show one row (full month actuals only).
Weekly: budget pro-rata now counts days Monday to yesterday for current week;
dept totals filtered to yesterday for current week.
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>
- Hide sidebar on mobile (≤640px), show fixed bottom nav bar instead
- Cards get overflow-x: auto so wide tables scroll horizontally
- Summary grid forces 2-column on mobile
- Reduce content padding to 12px, clear 68px for bottom nav
- Summary card values shrink to 18px on mobile
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>