Revert rota comparison to real oncosts; remove stale NI footnote

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>
This commit is contained in:
jtricerolph 2026-07-25 10:17:11 +00:00
parent e06d04d517
commit 1ffaece874
2 changed files with 15 additions and 37 deletions

View file

@ -9,11 +9,6 @@ export const DEFAULT_DAILY_TOKEN_BUDGET = 5000
const MAX_OUTPUT_TOKENS = 900
export const MANUAL_RATE_LIMIT_MINUTES = 5
// One-time context so the model doesn't flag the 25/07 rota-methodology fix (oncost-inclusive
// -> base-pay-only comparison) as an unexplained swing when it sees a prior insight's figures
// change. Safe to delete this constant and its usage below once it's a few days stale.
const METHOD_CHANGE_NOTE_UNTIL = '2026-07-29'
function toISODate(d) {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
@ -152,15 +147,16 @@ export async function gatherPriorPeriodData() {
// Department-level variance between scheduled (rota) and actual cost, over the trailing
// window. Rota rows for past dates aren't deleted once synced, so this covers real history.
//
// Always compares on BASE cost for both sides, regardless of the show_oncosts display
// setting: Workforce's schedules API never actually supplies employer NI oncosts (confirmed —
// published_total_cost is identical to published_base_cost on every synced row), so comparing
// oncost-inclusive actuals against rota would inflate every variance by ~14-20% for reasons
// that have nothing to do with real overspend. Matches the same caveat already surfaced on the
// Monthly page's rota-based forecast footnote.
// Respects show_oncosts like the rest of the app. Previously hardcoded to base cost because
// the old /api/v2/schedules-based sync never supplied real oncosts on rota data (confirmed:
// published_total_cost == published_base_cost on every row) — fixed 2026-07-25 by switching
// the sync to /api/v2/rosters/on/{date}, which does supply real cost_with_oncosts.
export async function gatherRotaVsActualData(days = 28) {
const actualCostCol = 'base_cost'
const schedCostExpr = '(published_base_cost + unpublished_base_cost)'
const showOncosts = (await getConfig('show_oncosts')) !== 'false'
const actualCostCol = showOncosts ? 'total_cost' : 'base_cost'
const schedCostExpr = showOncosts
? '(published_total_cost + unpublished_total_cost)'
: '(published_base_cost + unpublished_base_cost)'
const today = new Date()
const fromStr = toISODate(new Date(today.getTime() - days * 86_400_000))
@ -213,7 +209,7 @@ export async function gatherRotaVsActualData(days = 28) {
}))
.sort((a, b) => Math.abs(b.variance) - Math.abs(a.variance))
return { fromStr, toStr, totalDaysInWindow, depts }
return { fromStr, toStr, totalDaysInWindow, depts, costBasis: showOncosts ? 'incl. employer NI oncosts' : 'base pay only' }
}
// Forward-looking projection — reuses the app's existing tiered forecastDayCost() logic
@ -374,19 +370,6 @@ export function buildPrompt(monthProgress, priorPeriod, rotaVsActual, forecast,
lines.push('')
}
if (recentInsights.length && toISODate(new Date()) <= METHOD_CHANGE_NOTE_UNTIL) {
lines.push('## Methodology Note (25/07/2026)')
lines.push(
"The Rota vs Actual Variance comparison was corrected on 25/07/2026 to use base pay only on both " +
"sides (previously actual cost included employer NI oncosts while rota did not, since Workforce's " +
"schedules API never supplies oncosts — this inflated every rota variance figure by roughly 14-20%). " +
"If a recent previous insight below shows a notably different rota variance figure for the same " +
"department than today's, that is this correction taking effect, not a real change in performance — " +
"do not describe it as unexplained or needing clarification."
)
lines.push('')
}
if (recentInsights.length) {
lines.push('## Recent Previous Insights (most recent first — reference these, do not just repeat them)')
for (const r of recentInsights) {
@ -414,15 +397,13 @@ export function buildPrompt(monthProgress, priorPeriod, rotaVsActual, forecast,
for (const d of priorPeriod.lastYearDepts) lines.push(` ${d.department_name}: ${fmtMoney(d.cost)}`)
lines.push('')
lines.push(`## Rota vs Actual Variance (${fmtDateUK(rotaVsActual.fromStr)} - ${fmtDateUK(rotaVsActual.toStr)}, ${rotaVsActual.totalDaysInWindow} days)`)
lines.push(`## Rota vs Actual Variance (${fmtDateUK(rotaVsActual.fromStr)} - ${fmtDateUK(rotaVsActual.toStr)}, ${rotaVsActual.totalDaysInWindow} days, ${rotaVsActual.costBasis})`)
lines.push(
"Figures below are BASE PAY only (excluding employer NI) on both sides — Workforce's rota/schedules " +
"API doesn't supply oncosts, so this is the only basis that's genuinely comparable; do not describe " +
"this variance using total-cost figures from other sections. Variance is computed only over days that " +
"have rota data (\"days w/ rota\" below) — if that's well below the total window, treat the variance " +
"as partial/uncertain due to missing rota history, not a confirmed overspend, and say so explicitly."
"Variance is computed only over days that have rota data (\"days w/ rota\" below) — if that's well " +
"below the total window, treat the variance as partial/uncertain due to missing rota history, not a " +
"confirmed overspend, and say so explicitly rather than stating it as fact."
)
lines.push('Department | Actual base pay (days w/ rota) | Rota base pay | Variance | Variance % | Days w/ rota | Actual base pay (full window)')
lines.push('Department | Actual (days w/ rota) | Rota | Variance | Variance % | Days w/ rota | Actual (full window)')
for (const d of rotaVsActual.depts) {
lines.push(
`${d.department_name} | ${fmtMoney(d.actualOnRotaDays)} | ${fmtMoney(d.schedTotal)} | ` +