Monthly view: rota boundary, EOM PY card, % Budget headline; backfill 25 months

Rota forecasting: only trust Workforce rota for the current Mon–Sun week —
beyond that use prior-week actuals to avoid incomplete next-week rotas
deflating the EOM forecast.

% vs Budget cards (Weekly + Monthly): show as coloured headline number
(green/amber/red) instead of small pct-badge chip, matching other cards.

Forecast EOM: PY sub-label shows full prior-year month total + % delta,
replacing the static "rota + prior-week actual" note once PY data exists.
Achieves this by fetching the full PY month rather than MTD-capped range,
then deriving both MTD (for Actual MTD card) and full-month (for EOM card).

Backfill depth: 13 → 25 months so past months in Rolling 12 have PY wage
data available (viewing Dec 2024 needs Dec 2023 = 19 months ago).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-23 12:08:13 +00:00
parent a75aceb6e7
commit d04cd1e82c
4 changed files with 43 additions and 27 deletions

View file

@ -264,7 +264,7 @@ export async function runBackfill(onProgress, signal) {
endDate.setDate(endDate.getDate() - 1) endDate.setDate(endDate.getDate() - 1)
const startDate = new Date(today) const startDate = new Date(today)
startDate.setMonth(startDate.getMonth() - 13) startDate.setMonth(startDate.getMonth() - 25)
const totalDays = Math.max(1, Math.ceil((endDate - startDate) / 86_400_000)) const totalDays = Math.max(1, Math.ceil((endDate - startDate) / 86_400_000))
let processedDays = 0 let processedDays = 0

View file

@ -31,7 +31,8 @@ export default function Monthly() {
const [netSales, setNetSales] = useState(0) const [netSales, setNetSales] = useState(0)
const [pySales, setPySales] = useState(0) const [pySales, setPySales] = 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 [pyWagesFull, setPyWagesFull] = useState<number | null>(null)
const [pyDepts, setPyDepts] = useState<{id: string; name: string; cost: number; costFull: number}[]>([])
const [pyTableOpen, setPyTableOpen] = useState(false) const [pyTableOpen, setPyTableOpen] = useState(false)
const [budget, setBudget] = useState<number | null>(null) const [budget, setBudget] = useState<number | null>(null)
const [deptPcts, setDeptPcts] = useState<Record<string, number>>({}) const [deptPcts, setDeptPcts] = useState<Record<string, number>>({})
@ -49,12 +50,17 @@ export default function Monthly() {
const isCurrentMonth = year === todayYear && month === todayMonth const isCurrentMonth = year === todayYear && month === todayMonth
const salesTo = isCurrentMonth ? todayStr : toStr const salesTo = isCurrentMonth ? todayStr : toStr
const schedFrom = todayStr < toStr ? todayStr : toStr const schedFrom = todayStr < toStr ? todayStr : toStr
// Prior-year equivalent period // Current MonSun: only use rota within this window; beyond it use prior-week actuals
const currentWeekEndStr = (() => {
const d = new Date(todayStr + 'T00:00:00')
const day = d.getDay()
d.setDate(d.getDate() + (day === 0 ? 0 : 7 - day))
return fmt(d)
})()
// Prior-year: always fetch full month so we can show PY EOM alongside PY MTD
const pyDim = daysInMonth(year - 1, month) const pyDim = daysInMonth(year - 1, month)
const pyFromStr = `${year - 1}-${String(month).padStart(2, '0')}-01` const pyFromStr = `${year - 1}-${String(month).padStart(2, '0')}-01`
const pyToStr = isCurrentMonth const pyToStr = `${year - 1}-${String(month).padStart(2, '0')}-${String(pyDim).padStart(2, '0')}`
? `${year - 1}-${String(month).padStart(2, '0')}-${String(Math.min(parseInt(todayStr.slice(8)), pyDim)).padStart(2, '0')}`
: `${year - 1}-${String(month).padStart(2, '0')}-${String(pyDim).padStart(2, '0')}`
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setError(null) setLoading(true); setError(null)
@ -82,14 +88,21 @@ export default function Monthly() {
setNetSales(salesRes.days.reduce((s, d) => s + d.net_sales, 0)) setNetSales(salesRes.days.reduce((s, d) => s + d.net_sales, 0))
setPySales(salesRes.days.reduce((s, d) => s + d.py_sales, 0)) setPySales(salesRes.days.reduce((s, d) => s + d.py_sales, 0))
const pyDeptList = pyActRes.departments.map(dep => ({ // PY MTD limit: cap at same day number as today (or month end for past months)
id: dep.department_id, const todayDay = parseInt(todayStr.slice(8))
name: dep.department_name, const pyMtdLimit = isCurrentMonth
cost: Object.values(dep.days).reduce((s, d) => s + d.cost, 0), ? `${year - 1}-${String(month).padStart(2, '0')}-${String(Math.min(todayDay, pyDim)).padStart(2, '0')}`
})).filter(d => d.cost > 0).sort((a, b) => b.cost - a.cost) : pyToStr
const pyDeptList = pyActRes.departments.map(dep => {
const mtdCost = Object.entries(dep.days).filter(([d]) => d <= pyMtdLimit).reduce((s, [, v]) => s + v.cost, 0)
const fullCost = Object.values(dep.days).reduce((s, v) => s + v.cost, 0)
return { id: dep.department_id, name: dep.department_name, cost: mtdCost, costFull: fullCost }
}).filter(d => d.cost > 0 || d.costFull > 0).sort((a, b) => b.costFull - a.costFull)
setPyDepts(pyDeptList) setPyDepts(pyDeptList)
const pyTotal = pyDeptList.reduce((s, d) => s + d.cost, 0) const pyTotalMTD = pyDeptList.reduce((s, d) => s + d.cost, 0)
setPyWages(pyTotal > 0 ? pyTotal : null) const pyTotalFull = pyDeptList.reduce((s, d) => s + d.costFull, 0)
setPyWages(pyTotalMTD > 0 ? pyTotalMTD : null)
setPyWagesFull(pyTotalFull > 0 ? pyTotalFull : null)
const bRow = budRes.budgets.find((b: WageBudget) => b.month === fromStr) const bRow = budRes.budgets.find((b: WageBudget) => b.month === fromStr)
setBudget(bRow ? bRow.budget_amount : null) setBudget(bRow ? bRow.budget_amount : null)
@ -129,8 +142,11 @@ export default function Monthly() {
for (let day = 1; day <= dim; day++) { for (let day = 1; day <= dim; day++) {
const dateStr = `${monthStr}-${String(day).padStart(2, '0')}` const dateStr = `${monthStr}-${String(day).padStart(2, '0')}`
if (dateStr <= todayStr) continue if (dateStr <= todayStr) continue
const rota = getSchCost(dep.department_id, dateStr) // Only trust rota for current MonSun week; beyond that use prior-week actuals
if (rota != null) { forecastRem += rota; continue } if (dateStr <= currentWeekEndStr) {
const rota = getSchCost(dep.department_id, dateStr)
if (rota != null) { forecastRem += rota; continue }
}
const priorStr = fmt(addDays(new Date(dateStr + 'T00:00:00'), -7)) const priorStr = fmt(addDays(new Date(dateStr + 'T00:00:00'), -7))
const priorCost = dep.days[priorStr]?.cost const priorCost = dep.days[priorStr]?.cost
if (priorCost != null) forecastRem += priorCost if (priorCost != null) forecastRem += priorCost
@ -178,7 +194,7 @@ export default function Monthly() {
if (dateStr <= todayStr) { if (dateStr <= todayStr) {
deptCost += srcDep?.days[dateStr]?.cost ?? 0 deptCost += srcDep?.days[dateStr]?.cost ?? 0
} else { } else {
const rota = getSchCost(dep.department_id, dateStr) const rota = dateStr <= currentWeekEndStr ? getSchCost(dep.department_id, dateStr) : null
if (rota != null) { if (rota != null) {
deptCost += rota deptCost += rota
} else { } else {
@ -219,7 +235,11 @@ export default function Monthly() {
<div className="summary-card"> <div className="summary-card">
<div className="label">Forecast EOM</div> <div className="label">Forecast EOM</div>
<div className="value">{fmtMoney(totalForecast)}</div> <div className="value">{fmtMoney(totalForecast)}</div>
<div className="sub">rota + prior-week actual</div> <div className="sub">
{pyWagesFull != null
? `PY ${fmtMoney(pyWagesFull)}${pyPct(totalForecast, pyWagesFull)}`
: 'rota + prior-week actual'}
</div>
</div> </div>
)} )}
<div className="summary-card"> <div className="summary-card">
@ -228,10 +248,8 @@ export default function Monthly() {
</div> </div>
<div className="summary-card"> <div className="summary-card">
<div className="label">% Budget {isCurrentMonth ? '(Forecast)' : ''}</div> <div className="label">% Budget {isCurrentMonth ? '(Forecast)' : ''}</div>
<div className="value"> <div className="value" style={pctBudget != null ? { color: pctBudget <= 100 ? 'var(--app-primary)' : pctBudget <= 110 ? '#b45309' : '#dc2626' } : {}}>
{pctBudget != null {pctBudget != null ? fmtDelta(pctBudget) : '—'}
? <span className={`pct-badge ${pctClass(pctBudget)}`}>{fmtDelta(pctBudget)}</span>
: '—'}
</div> </div>
{variance != null && ( {variance != null && (
<div className={`sub ${variance > 0 ? 'variance-over' : 'variance-under'}`}> <div className={`sub ${variance > 0 ? 'variance-over' : 'variance-under'}`}>

View file

@ -97,7 +97,7 @@ export default function SettingsPage() {
} }
const handleBackfill = async () => { const handleBackfill = async () => {
if (!confirm('Start deep backfill? This will fetch ~13 months of Workforce data and may take a few minutes.')) return if (!confirm('Start deep backfill? This will fetch ~25 months of Workforce data and may take several minutes.')) return
setBackfillProg({ processed: 0, total: 1, current: '…' }) setBackfillProg({ processed: 0, total: 1, current: '…' })
setError(null) setError(null)
@ -240,7 +240,7 @@ export default function SettingsPage() {
</button> </button>
<button className="btn btn-secondary" onClick={handleBackfill} disabled={syncing || backfillProg != null}> <button className="btn btn-secondary" onClick={handleBackfill} disabled={syncing || backfillProg != null}>
<Download size={14} strokeWidth={1.75} /> <Download size={14} strokeWidth={1.75} />
Deep Backfill (13 months) Deep Backfill (25 months)
</button> </button>
{backfillProg && ( {backfillProg && (
<button className="btn btn-secondary" onClick={handleCancelBackfill}> <button className="btn btn-secondary" onClick={handleCancelBackfill}>

View file

@ -197,10 +197,8 @@ export default function Weekly() {
</div> </div>
<div className="summary-card"> <div className="summary-card">
<div className="label">% vs Budget</div> <div className="label">% vs Budget</div>
<div className="value"> <div className="value" style={pctBudget != null ? { color: pctBudget <= 100 ? 'var(--app-primary)' : pctBudget <= 110 ? '#b45309' : '#dc2626' } : {}}>
{pctBudget != null {pctBudget != null ? fmtDelta(pctBudget) : '—'}
? <span className={`pct-badge ${pctClass(pctBudget)}`}>{fmtDelta(pctBudget)}</span>
: '—'}
</div> </div>
</div> </div>
<div className="summary-card"> <div className="summary-card">