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)
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))
let processedDays = 0

View file

@ -31,7 +31,8 @@ export default function Monthly() {
const [netSales, setNetSales] = useState(0)
const [pySales, setPySales] = useState(0)
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 [budget, setBudget] = useState<number | null>(null)
const [deptPcts, setDeptPcts] = useState<Record<string, number>>({})
@ -49,12 +50,17 @@ export default function Monthly() {
const isCurrentMonth = year === todayYear && month === todayMonth
const salesTo = isCurrentMonth ? 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 pyFromStr = `${year - 1}-${String(month).padStart(2, '0')}-01`
const pyToStr = isCurrentMonth
? `${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 pyToStr = `${year - 1}-${String(month).padStart(2, '0')}-${String(pyDim).padStart(2, '0')}`
const load = useCallback(async () => {
setLoading(true); setError(null)
@ -82,14 +88,21 @@ export default function Monthly() {
setNetSales(salesRes.days.reduce((s, d) => s + d.net_sales, 0))
setPySales(salesRes.days.reduce((s, d) => s + d.py_sales, 0))
const pyDeptList = pyActRes.departments.map(dep => ({
id: dep.department_id,
name: dep.department_name,
cost: Object.values(dep.days).reduce((s, d) => s + d.cost, 0),
})).filter(d => d.cost > 0).sort((a, b) => b.cost - a.cost)
// PY MTD limit: cap at same day number as today (or month end for past months)
const todayDay = parseInt(todayStr.slice(8))
const pyMtdLimit = isCurrentMonth
? `${year - 1}-${String(month).padStart(2, '0')}-${String(Math.min(todayDay, pyDim)).padStart(2, '0')}`
: 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)
const pyTotal = pyDeptList.reduce((s, d) => s + d.cost, 0)
setPyWages(pyTotal > 0 ? pyTotal : null)
const pyTotalMTD = pyDeptList.reduce((s, d) => s + d.cost, 0)
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)
setBudget(bRow ? bRow.budget_amount : null)
@ -129,8 +142,11 @@ export default function Monthly() {
for (let day = 1; day <= dim; day++) {
const dateStr = `${monthStr}-${String(day).padStart(2, '0')}`
if (dateStr <= todayStr) continue
const rota = getSchCost(dep.department_id, dateStr)
if (rota != null) { forecastRem += rota; continue }
// Only trust rota for current MonSun week; beyond that use prior-week actuals
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 priorCost = dep.days[priorStr]?.cost
if (priorCost != null) forecastRem += priorCost
@ -178,7 +194,7 @@ export default function Monthly() {
if (dateStr <= todayStr) {
deptCost += srcDep?.days[dateStr]?.cost ?? 0
} else {
const rota = getSchCost(dep.department_id, dateStr)
const rota = dateStr <= currentWeekEndStr ? getSchCost(dep.department_id, dateStr) : null
if (rota != null) {
deptCost += rota
} else {
@ -219,7 +235,11 @@ export default function Monthly() {
<div className="summary-card">
<div className="label">Forecast EOM</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 className="summary-card">
@ -228,10 +248,8 @@ export default function Monthly() {
</div>
<div className="summary-card">
<div className="label">% Budget {isCurrentMonth ? '(Forecast)' : ''}</div>
<div className="value">
{pctBudget != null
? <span className={`pct-badge ${pctClass(pctBudget)}`}>{fmtDelta(pctBudget)}</span>
: '—'}
<div className="value" style={pctBudget != null ? { color: pctBudget <= 100 ? 'var(--app-primary)' : pctBudget <= 110 ? '#b45309' : '#dc2626' } : {}}>
{pctBudget != null ? fmtDelta(pctBudget) : '—'}
</div>
{variance != null && (
<div className={`sub ${variance > 0 ? 'variance-over' : 'variance-under'}`}>

View file

@ -97,7 +97,7 @@ export default function SettingsPage() {
}
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: '…' })
setError(null)
@ -240,7 +240,7 @@ export default function SettingsPage() {
</button>
<button className="btn btn-secondary" onClick={handleBackfill} disabled={syncing || backfillProg != null}>
<Download size={14} strokeWidth={1.75} />
Deep Backfill (13 months)
Deep Backfill (25 months)
</button>
{backfillProg && (
<button className="btn btn-secondary" onClick={handleCancelBackfill}>

View file

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