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 const MAX_OUTPUT_TOKENS = 900
export const MANUAL_RATE_LIMIT_MINUTES = 5 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) { function toISODate(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')}`
} }
@ -152,15 +147,16 @@ export async function gatherPriorPeriodData() {
// Department-level variance between scheduled (rota) and actual cost, over the trailing // 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. // 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 // Respects show_oncosts like the rest of the app. Previously hardcoded to base cost because
// setting: Workforce's schedules API never actually supplies employer NI oncosts (confirmed — // the old /api/v2/schedules-based sync never supplied real oncosts on rota data (confirmed:
// published_total_cost is identical to published_base_cost on every synced row), so comparing // published_total_cost == published_base_cost on every row) — fixed 2026-07-25 by switching
// oncost-inclusive actuals against rota would inflate every variance by ~14-20% for reasons // the sync to /api/v2/rosters/on/{date}, which does supply real cost_with_oncosts.
// that have nothing to do with real overspend. Matches the same caveat already surfaced on the
// Monthly page's rota-based forecast footnote.
export async function gatherRotaVsActualData(days = 28) { export async function gatherRotaVsActualData(days = 28) {
const actualCostCol = 'base_cost' const showOncosts = (await getConfig('show_oncosts')) !== 'false'
const schedCostExpr = '(published_base_cost + unpublished_base_cost)' 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 today = new Date()
const fromStr = toISODate(new Date(today.getTime() - days * 86_400_000)) 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)) .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 // Forward-looking projection — reuses the app's existing tiered forecastDayCost() logic
@ -374,19 +370,6 @@ export function buildPrompt(monthProgress, priorPeriod, rotaVsActual, forecast,
lines.push('') 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) { if (recentInsights.length) {
lines.push('## Recent Previous Insights (most recent first — reference these, do not just repeat them)') lines.push('## Recent Previous Insights (most recent first — reference these, do not just repeat them)')
for (const r of recentInsights) { 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)}`) for (const d of priorPeriod.lastYearDepts) lines.push(` ${d.department_name}: ${fmtMoney(d.cost)}`)
lines.push('') 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( lines.push(
"Figures below are BASE PAY only (excluding employer NI) on both sides — Workforce's rota/schedules " + "Variance is computed only over days that have rota data (\"days w/ rota\" below) — if that's well " +
"API doesn't supply oncosts, so this is the only basis that's genuinely comparable; do not describe " + "below the total window, treat the variance as partial/uncertain due to missing rota history, not a " +
"this variance using total-cost figures from other sections. Variance is computed only over days that " + "confirmed overspend, and say so explicitly rather than stating it as fact."
"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."
) )
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) { for (const d of rotaVsActual.depts) {
lines.push( lines.push(
`${d.department_name} | ${fmtMoney(d.actualOnRotaDays)} | ${fmtMoney(d.schedTotal)} | ` + `${d.department_name} | ${fmtMoney(d.actualOnRotaDays)} | ${fmtMoney(d.schedTotal)} | ` +

View file

@ -508,9 +508,6 @@ export default function Monthly() {
</tbody> </tbody>
</table> </table>
{showOncosts && <p className="footnote">Includes estimated employer on-costs. Final payroll figures are in Sage.</p>} {showOncosts && <p className="footnote">Includes estimated employer on-costs. Final payroll figures are in Sage.</p>}
{isCurrentMonth && forecastMethod === 'rota' && (
<p className="footnote">Rota-based forecast figures exclude employer National Insurance Workforce's schedules API doesn't provide it, only the timesheets/actuals API does.</p>
)}
</div> </div>
{pyDepts.length > 0 && ( {pyDepts.length > 0 && (