From a1a89001967c2884de0bc1c78ee6a10da98fbb62 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Thu, 23 Jul 2026 21:18:30 +0000 Subject: [PATCH] Fix current-period % Budget bugs on Rolling 12 Weeks/Months tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/src/pages/Rolling12Months.tsx | 15 +++++- frontend/src/pages/Rolling12Weeks.tsx | 71 +++++++++++++++++++++----- 2 files changed, 72 insertions(+), 14 deletions(-) diff --git a/frontend/src/pages/Rolling12Months.tsx b/frontend/src/pages/Rolling12Months.tsx index fba4c60..db04f68 100644 --- a/frontend/src/pages/Rolling12Months.tsx +++ b/frontend/src/pages/Rolling12Months.tsx @@ -90,8 +90,19 @@ export default function Rolling12Months() { if (date >= monthFrom && date <= effectiveTo) { sales += val.sales; pySales += val.py } } - const monKey = `${monthFrom}` - const budget = budgetMap[monKey] ?? null + // 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 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)}` tableRows.push({ label, wages, budget, sales, pySales, partial: isCurrentMonth }) diff --git a/frontend/src/pages/Rolling12Weeks.tsx b/frontend/src/pages/Rolling12Weeks.tsx index 6f07d3e..351b6bb 100644 --- a/frontend/src/pages/Rolling12Weeks.tsx +++ b/frontend/src/pages/Rolling12Weeks.tsx @@ -18,7 +18,14 @@ function startOfWeek(d: Date): Date { r.setHours(0, 0, 0, 0) 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 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)}%` } @@ -43,10 +50,24 @@ export default function Rolling12Weeks() { const rangeStart = addDays(thisMonday, -12 * 7) 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() + 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)), getNetSales(fmt(rangeStart), fmt(rangeEnd)), getBudgets(), + Promise.all(monthRanges.map(r => getNetSales(r.from, r.to))), ]) const salesByDate: Record = {} @@ -54,9 +75,30 @@ export default function Rolling12Weeks() { salesByDate[d.date] = { sales: d.net_sales, py: d.py_sales } } - const budgetMap: Record = {} + // Per-day PY sales and per-month PY totals, used to weight each week's budget share + const pySalesByDay: Record = {} + const monthPyTotals: Record = {} + 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 = {} // monthKey (YYYY-MM) -> amount 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 @@ -95,20 +137,25 @@ export default function Rolling12Weeks() { pySales += salesByDate[ds]?.py ?? 0 } - // Pro-rata budget - const monStr = `${wStart.getFullYear()}-${String(wStart.getMonth() + 1).padStart(2, '0')}-01` - const monthBudget = budgetMap[monStr] - const budget = monthBudget != null - ? monthBudget * (isPartial ? (Math.ceil((today.getTime() - wStart.getTime()) / 86_400_000) + 1) : 7) / daysInMonth(wStart) - : null + // Budget: sum of each elapsed day's own-month, PY-weighted share (see dayBudget above) + let budget = 0 + let anyBudget = false + for (let i = 0; i <= 6; i++) { + const d = addDays(wStart, i) + 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' })}` - tableRows.push({ label, wages, budget, sales, pySales, partial: isPartial }) + tableRows.push({ label, wages, budget: budgetVal, sales, pySales, partial: isPartial }) const cdRow: Record = { label } for (const dep of depts) cdRow[dep.department_name] = deptWages[dep.department_id] ?? 0 cdRow._wages = wages - cdRow._budget = budget ?? 0 + cdRow._budget = budgetVal ?? 0 cData.push(cdRow) }