Weight WTD/MTD budget split by PY DOW-matched sales instead of flat day count

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>
This commit is contained in:
jtricerolph 2026-07-23 17:57:09 +00:00
parent baba818693
commit 47e899f636
2 changed files with 73 additions and 20 deletions

View file

@ -51,6 +51,7 @@ export default function Monthly() {
const [pyTableOpen, setPyTableOpen] = useState(false) const [pyTableOpen, setPyTableOpen] = useState(false)
const [prevMonthDepts, setPrevMonthDepts] = useState<{id: string; cost: number}[]>([]) const [prevMonthDepts, setPrevMonthDepts] = useState<{id: string; cost: number}[]>([])
const [budget, setBudget] = useState<number | null>(null) const [budget, setBudget] = useState<number | null>(null)
const [pySalesByDay, setPySalesByDay] = useState<Record<string, number>>({})
const [deptPcts, setDeptPcts] = useState<Record<string, number>>({}) const [deptPcts, setDeptPcts] = useState<Record<string, number>>({})
const [showOncosts, setShowOncosts] = useState(true) const [showOncosts, setShowOncosts] = useState(true)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@ -100,6 +101,10 @@ export default function Monthly() {
setPySalesMTD(salesRes.days.filter(d => d.date <= cutoff).reduce((s, d) => s + d.py_sales, 0)) setPySalesMTD(salesRes.days.filter(d => d.date <= cutoff).reduce((s, d) => s + d.py_sales, 0))
setPySalesFull(salesRes.days.reduce((s, d) => s + d.py_sales, 0)) setPySalesFull(salesRes.days.reduce((s, d) => s + d.py_sales, 0))
const pyMap: Record<string, number> = {}
for (const d of salesRes.days) pyMap[d.date] = d.py_sales
setPySalesByDay(pyMap)
// PY MTD: same day number as yesterday in PY (or full month for past months) // PY MTD: same day number as yesterday in PY (or full month for past months)
const yDay = parseInt(yesterdayStr.slice(8)) const yDay = parseInt(yesterdayStr.slice(8))
const pyMtdLimit = isCurrentMonth const pyMtdLimit = isCurrentMonth
@ -179,9 +184,16 @@ export default function Monthly() {
const pyTotalWagesFull = pyDepts.reduce((s, d) => s + d.costFull, 0) const pyTotalWagesFull = pyDepts.reduce((s, d) => s + d.costFull, 0)
const prevMonthTotal = prevMonthDepts.reduce((s, d) => s + d.cost, 0) const prevMonthTotal = prevMonthDepts.reduce((s, d) => s + d.cost, 0)
// MTD row: pro-rata budget to yesterday // MTD row: budget split across the month by each day's share of PY (DOW-matched) sales,
const yDay = isCurrentMonth ? parseInt(yesterdayStr.slice(8)) : dim // so a weekend-heavy budget lands more on weekends rather than an even calendar split.
const budgetMTD = budget != null ? budget * yDay / dim : null // Falls back to flat day-count proration when PY sales data isn't available.
const yDay = isCurrentMonth ? parseInt(yesterdayStr.slice(8)) : dim
const monthPyTotal = Object.values(pySalesByDay).reduce((s, v) => s + v, 0)
const isBudgetWeighted = monthPyTotal > 0
const mtdFrac = isBudgetWeighted
? Object.entries(pySalesByDay).filter(([d]) => d <= cutoff).reduce((s, [, v]) => s + v, 0) / monthPyTotal
: yDay / dim
const budgetMTD = budget != null ? budget * mtdFrac : null
const varianceMTD = budgetMTD != null ? totalActual - budgetMTD : null const varianceMTD = budgetMTD != null ? totalActual - budgetMTD : null
const pctBudgMTD = budgetMTD != null && budgetMTD > 0 ? (totalActual / budgetMTD) * 100 : null const pctBudgMTD = budgetMTD != null && budgetMTD > 0 ? (totalActual / budgetMTD) * 100 : null
const pctSalesMTD = netSalesMTD > 0 ? (totalActual / netSalesMTD) * 100 : null const pctSalesMTD = netSalesMTD > 0 ? (totalActual / netSalesMTD) * 100 : null
@ -253,7 +265,7 @@ export default function Monthly() {
<div className="summary-card"> <div className="summary-card">
<div className="label">Budget MTD</div> <div className="label">Budget MTD</div>
<div className="value">{budgetMTD != null ? fmtMoney(budgetMTD) : '—'}</div> <div className="value">{budgetMTD != null ? fmtMoney(budgetMTD) : '—'}</div>
<div className="sub">pro-rata {yDay}/{dim} days</div> <div className="sub">{isBudgetWeighted ? 'sales-weighted (PY DOW)' : `pro-rata ${yDay}/${dim} days`}</div>
</div> </div>
<div className="summary-card"> <div className="summary-card">
<div className="label">% Budget MTD</div> <div className="label">% Budget MTD</div>

View file

@ -23,6 +23,13 @@ function daysInMonthFor(dateStr: string): number {
const d = new Date(dateStr + 'T00:00:00') const d = new Date(dateStr + 'T00:00:00')
return new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate() return new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate()
} }
function monthKeyOf(dateStr: string): string {
return dateStr.slice(0, 7)
}
function monthRangeOf(monthKey: string): { from: string; to: string } {
const from = `${monthKey}-01`
return { from, to: `${monthKey}-${String(daysInMonthFor(from)).padStart(2, '0')}` }
}
function fmtDisplay(dateStr: string): string { function fmtDisplay(dateStr: string): string {
return new Date(dateStr + 'T00:00:00').toLocaleDateString('en-GB', { day: 'numeric', month: 'short' }) return new Date(dateStr + 'T00:00:00').toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })
} }
@ -67,7 +74,9 @@ export default function Weekly() {
const [pySalesFull, setPySalesFull] = useState(0) const [pySalesFull, setPySalesFull] = useState(0)
const [pyWages, setPyWages] = useState<number | null>(null) const [pyWages, setPyWages] = useState<number | null>(null)
const [pyDepts, setPyDepts] = useState<{id: string; name: string; cost: number}[]>([]) const [pyDepts, setPyDepts] = useState<{id: string; name: string; cost: number}[]>([])
const [monthlyBudg, setMonthlyBudg] = useState<number | null>(null) const [budgetsByMonth, setBudgetsByMonth] = useState<Record<string, number>>({}) // monthKey (YYYY-MM) -> amount
const [pySalesByDay, setPySalesByDay] = useState<Record<string, number>>({}) // date -> PY (DOW-matched) sales
const [monthPyTotals, setMonthPyTotals] = useState<Record<string, number>>({}) // monthKey -> total PY sales
const [deptPcts, setDeptPcts] = useState<Record<string, number>>({}) const [deptPcts, setDeptPcts] = useState<Record<string, number>>({})
const [showOncosts, setShowOncosts] = useState(true) const [showOncosts, setShowOncosts] = useState(true)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@ -76,17 +85,40 @@ export default function Weekly() {
const [modalEmps, setModalEmps] = useState<EmployeeDetail[] | null>(null) const [modalEmps, setModalEmps] = useState<EmployeeDetail[] | null>(null)
const [modalLoad, setModalLoad] = useState(false) const [modalLoad, setModalLoad] = useState(false)
// A week can span two calendar months (e.g. Mon 26 Sun 1) — budget/PY-sales weighting
// must be sourced per-day from whichever month that day actually belongs to.
const weekMonthKeys = Array.from(new Set([monthKeyOf(fromStr), monthKeyOf(toStr)]))
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setError(null) setLoading(true); setError(null)
try { try {
const isCurrentWk = fromStr === mondayOf(new Date()) const isCurrentWk = fromStr === mondayOf(new Date())
const [actRes, salesRes, budgetRes, pyActRes] = await Promise.all([ const monthKeys = Array.from(new Set([monthKeyOf(fromStr), monthKeyOf(toStr)]))
const monthRanges = monthKeys.map(monthRangeOf)
const [actRes, salesRes, budgetRes, pyActRes, monthSalesResList] = await Promise.all([
// 14-day fetch: prev week + current week so prev-week data is in dep.days for forecast + comparison // 14-day fetch: prev week + current week so prev-week data is in dep.days for forecast + comparison
getActuals(prevWeekFrom, toStr), getActuals(prevWeekFrom, toStr),
getNetSales(fromStr, toStr), getNetSales(fromStr, toStr),
getBudgets(), getBudgets(),
getActuals(pyFromStr, pyToStr), getActuals(pyFromStr, pyToStr),
Promise.all(monthRanges.map(r => getNetSales(r.from, r.to))),
]) ])
const pyMap: Record<string, number> = {}
const monthTotals: Record<string, number> = {}
monthKeys.forEach((key, i) => {
let total = 0
for (const d of monthSalesResList[i].days) { pyMap[d.date] = d.py_sales; total += d.py_sales }
monthTotals[key] = total
})
setPySalesByDay(pyMap)
setMonthPyTotals(monthTotals)
const budgMap: Record<string, number> = {}
for (const b of (budgetRes.budgets as WageBudget[])) budgMap[b.month.slice(0, 7)] = b.budget_amount
setBudgetsByMonth(budgMap)
setDepts(actRes.departments) setDepts(actRes.departments)
setShowOncosts(actRes.show_oncosts) setShowOncosts(actRes.show_oncosts)
setDeptPcts(actRes.dept_pcts) setDeptPcts(actRes.dept_pcts)
@ -107,12 +139,6 @@ export default function Weekly() {
setPyDepts(pyDeptList) setPyDepts(pyDeptList)
const pyTotal = pyDeptList.reduce((s, d) => s + d.cost, 0) const pyTotal = pyDeptList.reduce((s, d) => s + d.cost, 0)
setPyWages(pyTotal > 0 ? pyTotal : null) setPyWages(pyTotal > 0 ? pyTotal : null)
// Budget: WTD pro-rata (to yesterday) stored; full-week derived in component body
const d0 = new Date(fromStr + 'T00:00:00')
const monthKey = `${d0.getFullYear()}-${String(d0.getMonth() + 1).padStart(2, '0')}-01`
const bRow = (budgetRes.budgets as WageBudget[]).find(b => b.month === monthKey)
setMonthlyBudg(bRow ? bRow.budget_amount : null)
} catch (e: unknown) { } catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Failed to load') setError(e instanceof Error ? e.message : 'Failed to load')
} finally { } finally {
@ -134,17 +160,32 @@ export default function Weekly() {
const prev = () => setFromStr(s => addDaysStr(s, -7)) const prev = () => setFromStr(s => addDaysStr(s, -7))
const next = () => setFromStr(s => addDaysStr(s, 7)) const next = () => setFromStr(s => addDaysStr(s, 7))
// Budget derived from monthlyBudg
const dim = daysInMonthFor(fromStr)
const weekDaysWTD = daysElapsed + 1 // Mon to yesterday inclusive (or 7 for past weeks)
const budgetWTD = monthlyBudg != null ? monthlyBudg * weekDaysWTD / dim : null
const budgetFull = monthlyBudg != null ? monthlyBudg * 7 / dim : null
// Per-dept rows computed from 14-day actuals // Per-dept rows computed from 14-day actuals
const wtdCutoff = isCurrentWeek ? yesterdayStr : toStr const wtdCutoff = isCurrentWeek ? yesterdayStr : toStr
// Prev week comparison cutoff: same elapsed days as WTD (Mon-N of prev week) // Prev week comparison cutoff: same elapsed days as WTD (Mon-N of prev week)
const prevCutoff = isCurrentWeek ? addDaysStr(yesterdayStr, -7) : prevWeekTo const prevCutoff = isCurrentWeek ? addDaysStr(yesterdayStr, -7) : prevWeekTo
// Budget for a single day: sourced from THAT day's own calendar month (a week can span two
// months), 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 = (dateStr: string): number => {
const mKey = monthKeyOf(dateStr)
const monthBudget = budgetsByMonth[mKey]
if (monthBudget == null) return 0
const monthTotal = monthPyTotals[mKey] ?? 0
if (monthTotal > 0) return monthBudget * ((pySalesByDay[dateStr] ?? 0) / monthTotal)
return monthBudget / daysInMonthFor(`${mKey}-01`)
}
const sumBudget = (fromD: string, toD: string): number => {
let s = 0
for (let d = fromD; d <= toD; d = addDaysStr(d, 1)) s += dayBudget(d)
return s
}
const hasBudget = weekMonthKeys.some(k => budgetsByMonth[k] != null)
const isBudgetWeighted = weekMonthKeys.every(k => (monthPyTotals[k] ?? 0) > 0)
const budgetWTD = hasBudget ? sumBudget(fromStr, wtdCutoff) : null
const budgetFull = hasBudget ? sumBudget(fromStr, toStr) : null
type DeptRow = { type DeptRow = {
department_id: string department_id: string
department_name: string department_name: string
@ -231,7 +272,7 @@ export default function Weekly() {
<div className="summary-card"> <div className="summary-card">
<div className="label">Budget WTD</div> <div className="label">Budget WTD</div>
<div className="value">{budgetWTD != null ? fmtMoney(budgetWTD) : '—'}</div> <div className="value">{budgetWTD != null ? fmtMoney(budgetWTD) : '—'}</div>
<div className="sub">proportion of monthly</div> <div className="sub">{isBudgetWeighted ? 'sales-weighted (PY DOW)' : 'proportion of monthly'}</div>
</div> </div>
<div className="summary-card"> <div className="summary-card">
<div className="label">% Budget WTD</div> <div className="label">% Budget WTD</div>
@ -263,7 +304,7 @@ export default function Weekly() {
<div className="summary-card"> <div className="summary-card">
<div className="label">Budget Full Week</div> <div className="label">Budget Full Week</div>
<div className="value">{budgetFull != null ? fmtMoney(budgetFull) : '—'}</div> <div className="value">{budgetFull != null ? fmtMoney(budgetFull) : '—'}</div>
<div className="sub">7-day proportion</div> <div className="sub">{isBudgetWeighted ? 'sales-weighted (PY DOW)' : '7-day proportion'}</div>
</div> </div>
<div className="summary-card"> <div className="summary-card">
<div className="label">% Budget (Forecast)</div> <div className="label">% Budget (Forecast)</div>