Fix current-period % Budget bugs on Rolling 12 Weeks/Months tables

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>
This commit is contained in:
jtricerolph 2026-07-23 21:18:30 +00:00
parent 47e899f636
commit a1a8900196
2 changed files with 72 additions and 14 deletions

View file

@ -90,8 +90,19 @@ export default function Rolling12Months() {
if (date >= monthFrom && date <= effectiveTo) { sales += val.sales; pySales += val.py } if (date >= monthFrom && date <= effectiveTo) { sales += val.sales; pySales += val.py }
} }
// Budget: prorated to elapsed-to-date by each day's share of the month's PY (DOW-matched)
// sales — falls back to flat day-count when PY data is missing. For complete past months
// effectiveTo === monthTo, so this always resolves to the full budget (fraction = 1).
let monthPyTotal = 0
for (const [date, val] of Object.entries(salesByDate)) {
if (date >= monthFrom && date <= monthTo) monthPyTotal += val.py
}
const monKey = `${monthFrom}` const monKey = `${monthFrom}`
const budget = budgetMap[monKey] ?? null const budgetRaw = budgetMap[monKey] ?? null
const mtdFrac = monthPyTotal > 0
? pySales / monthPyTotal
: parseInt(effectiveTo.slice(8), 10) / dim
const budget = budgetRaw != null ? budgetRaw * mtdFrac : null
const label = `${MONTH_LABELS[month - 1]}-${String(year).slice(2)}` const label = `${MONTH_LABELS[month - 1]}-${String(year).slice(2)}`
tableRows.push({ label, wages, budget, sales, pySales, partial: isCurrentMonth }) tableRows.push({ label, wages, budget, sales, pySales, partial: isCurrentMonth })

View file

@ -18,7 +18,14 @@ function startOfWeek(d: Date): Date {
r.setHours(0, 0, 0, 0) r.setHours(0, 0, 0, 0)
return r return r
} }
function daysInMonth(d: Date): number { return new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate() } function monthKeyOf(dateStr: string): string { return dateStr.slice(0, 7) }
function daysInMonthKey(monthKey: string): number {
const [y, m] = monthKey.split('-').map(Number)
return new Date(y, m, 0).getDate()
}
function monthRangeOf(monthKey: string): { from: string; to: string } {
return { from: `${monthKey}-01`, to: `${monthKey}-${String(daysInMonthKey(monthKey)).padStart(2, '0')}` }
}
function fmtMoney(n: number): string { return `£${Math.round(n).toLocaleString('en-GB')}` } function fmtMoney(n: number): string { return `£${Math.round(n).toLocaleString('en-GB')}` }
function pctClass(p: number): string { return p <= 100 ? 'pct-green' : p <= 110 ? 'pct-amber' : 'pct-red' } function pctClass(p: number): string { return p <= 100 ? 'pct-green' : p <= 110 ? 'pct-amber' : 'pct-red' }
function fmtDelta(p: number): string { const d = p - 100; return `${d >= 0 ? '+' : ''}${d.toFixed(1)}%` } function fmtDelta(p: number): string { const d = p - 100; return `${d >= 0 ? '+' : ''}${d.toFixed(1)}%` }
@ -43,10 +50,24 @@ export default function Rolling12Weeks() {
const rangeStart = addDays(thisMonday, -12 * 7) const rangeStart = addDays(thisMonday, -12 * 7)
const rangeEnd = addDays(thisMonday, 6) // end of current week const rangeEnd = addDays(thisMonday, 6) // end of current week
const [actRes, salesRes, budRes] = await Promise.all([ // Budget is monthly, so weighting each week's share needs full-month PY sales as the
// denominator — gather every distinct calendar month touched by any of the 13 weeks
// (a week can span two months) and fetch each one's full range.
const monthKeySet = new Set<string>()
for (let w = 0; w < 13; w++) {
const wStart = addDays(rangeStart, w * 7)
const wEnd = addDays(wStart, 6)
monthKeySet.add(monthKeyOf(fmt(wStart)))
monthKeySet.add(monthKeyOf(fmt(wEnd)))
}
const monthKeys = Array.from(monthKeySet)
const monthRanges = monthKeys.map(monthRangeOf)
const [actRes, salesRes, budRes, monthSalesResList] = await Promise.all([
getActuals(fmt(rangeStart), fmt(rangeEnd)), getActuals(fmt(rangeStart), fmt(rangeEnd)),
getNetSales(fmt(rangeStart), fmt(rangeEnd)), getNetSales(fmt(rangeStart), fmt(rangeEnd)),
getBudgets(), getBudgets(),
Promise.all(monthRanges.map(r => getNetSales(r.from, r.to))),
]) ])
const salesByDate: Record<string, { sales: number; py: number }> = {} const salesByDate: Record<string, { sales: number; py: number }> = {}
@ -54,9 +75,30 @@ export default function Rolling12Weeks() {
salesByDate[d.date] = { sales: d.net_sales, py: d.py_sales } salesByDate[d.date] = { sales: d.net_sales, py: d.py_sales }
} }
const budgetMap: Record<string, number> = {} // Per-day PY sales and per-month PY totals, used to weight each week's budget share
const pySalesByDay: Record<string, number> = {}
const monthPyTotals: Record<string, number> = {}
monthKeys.forEach((key, i) => {
let total = 0
for (const d of monthSalesResList[i].days) { pySalesByDay[d.date] = d.py_sales; total += d.py_sales }
monthPyTotals[key] = total
})
const budgetMap: Record<string, number> = {} // monthKey (YYYY-MM) -> amount
for (const b of budRes.budgets as WageBudget[]) { for (const b of budRes.budgets as WageBudget[]) {
budgetMap[b.month] = b.budget_amount budgetMap[b.month.slice(0, 7)] = b.budget_amount
}
// Budget for a single day: sourced from that day's own calendar month, weighted by its
// share of that month's PY (DOW-matched) sales — falls back to a flat day-count split
// within that month when PY sales data isn't available.
const dayBudget = (ds: string): number => {
const mKey = monthKeyOf(ds)
const monthBudget = budgetMap[mKey]
if (monthBudget == null) return 0
const monthTotal = monthPyTotals[mKey] ?? 0
if (monthTotal > 0) return monthBudget * ((pySalesByDay[ds] ?? 0) / monthTotal)
return monthBudget / daysInMonthKey(mKey)
} }
// Dept lookup // Dept lookup
@ -95,20 +137,25 @@ export default function Rolling12Weeks() {
pySales += salesByDate[ds]?.py ?? 0 pySales += salesByDate[ds]?.py ?? 0
} }
// Pro-rata budget // Budget: sum of each elapsed day's own-month, PY-weighted share (see dayBudget above)
const monStr = `${wStart.getFullYear()}-${String(wStart.getMonth() + 1).padStart(2, '0')}-01` let budget = 0
const monthBudget = budgetMap[monStr] let anyBudget = false
const budget = monthBudget != null for (let i = 0; i <= 6; i++) {
? monthBudget * (isPartial ? (Math.ceil((today.getTime() - wStart.getTime()) / 86_400_000) + 1) : 7) / daysInMonth(wStart) const d = addDays(wStart, i)
: null if (d > effectiveEnd) break
const ds = fmt(d)
if (budgetMap[monthKeyOf(ds)] != null) anyBudget = true
budget += dayBudget(ds)
}
const budgetVal = anyBudget ? budget : null
const label = `w/e ${wEnd.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })}` const label = `w/e ${wEnd.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })}`
tableRows.push({ label, wages, budget, sales, pySales, partial: isPartial }) tableRows.push({ label, wages, budget: budgetVal, sales, pySales, partial: isPartial })
const cdRow: Record<string, number | string> = { label } const cdRow: Record<string, number | string> = { label }
for (const dep of depts) cdRow[dep.department_name] = deptWages[dep.department_id] ?? 0 for (const dep of depts) cdRow[dep.department_name] = deptWages[dep.department_id] ?? 0
cdRow._wages = wages cdRow._wages = wages
cdRow._budget = budget ?? 0 cdRow._budget = budgetVal ?? 0
cData.push(cdRow) cData.push(cdRow)
} }