Add DOW-aligned LY toggle to directors forecast worksheet and report

Checkbox in the header switches between 364-day same-weekday comparison
and same calendar-date last year. Default remains DOW-aligned.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-21 18:29:02 +00:00
parent 2e8a908f05
commit 9530125509
4 changed files with 33 additions and 20 deletions

View file

@ -41,12 +41,13 @@ async function getSessionAndOverrides(year, month) {
return { session, overrideMap } return { session, overrideMap }
} }
async function fetchMonthFromApi(year, month, lastDayNum) { async function fetchMonthFromApi(year, month, lastDayNum, dowAlign = true) {
const startDate = `${year}-${String(month).padStart(2, '0')}-01` const startDate = `${year}-${String(month).padStart(2, '0')}-01`
const days = lastDayNum const days = lastDayNum
const da = `&dow_align=${dowAlign}`
const [revData, roomsData] = await Promise.all([ const [revData, roomsData] = await Promise.all([
fcFetch(`/forecast/revenue?start_date=${startDate}&days=${days}&type=all`), fcFetch(`/forecast/revenue?start_date=${startDate}&days=${days}&type=all${da}`),
fcFetch(`/forecast/rooms?start_date=${startDate}&days=${days}`), fcFetch(`/forecast/rooms?start_date=${startDate}&days=${days}${da}`),
]) ])
const revMap = {} const revMap = {}
for (const d of revData.data) revMap[d.date] = d for (const d of revData.data) revMap[d.date] = d
@ -65,6 +66,7 @@ export async function directorsForecastRoutes(fastify) {
if (isNaN(year) || isNaN(month) || month < 1 || month > 12) { if (isNaN(year) || isNaN(month) || month < 1 || month > 12) {
return reply.status(400).send({ error: 'Invalid year/month' }) return reply.status(400).send({ error: 'Invalid year/month' })
} }
const dowAlign = request.query.dow_align !== 'false'
const lastDayNum = new Date(year, month, 0).getDate() const lastDayNum = new Date(year, month, 0).getDate()
const today = new Date().toISOString().split('T')[0] const today = new Date().toISOString().split('T')[0]
@ -72,7 +74,7 @@ export async function directorsForecastRoutes(fastify) {
const [{ session, overrideMap }, { revMap, roomsMap }] = await Promise.all([ const [{ session, overrideMap }, { revMap, roomsMap }] = await Promise.all([
getSessionAndOverrides(year, month), getSessionAndOverrides(year, month),
fetchMonthFromApi(year, month, lastDayNum), fetchMonthFromApi(year, month, lastDayNum, dowAlign),
]) ])
const rate = parseFloat(session.pickup_avg_rate || sessionRate) const rate = parseFloat(session.pickup_avg_rate || sessionRate)
@ -186,10 +188,11 @@ export async function directorsForecastRoutes(fastify) {
const year = parseInt(request.params.year) const year = parseInt(request.params.year)
const month = parseInt(request.params.month) const month = parseInt(request.params.month)
const lastDayNum = new Date(year, month, 0).getDate() const lastDayNum = new Date(year, month, 0).getDate()
const dowAlign = request.query.dow_align !== 'false'
const [{ session, overrideMap }, { revMap, roomsMap }] = await Promise.all([ const [{ session, overrideMap }, { revMap, roomsMap }] = await Promise.all([
getSessionAndOverrides(year, month), getSessionAndOverrides(year, month),
fetchMonthFromApi(year, month, lastDayNum), fetchMonthFromApi(year, month, lastDayNum, dowAlign),
]) ])
const sessionRate = parseFloat(session.pickup_avg_rate || 135) const sessionRate = parseFloat(session.pickup_avg_rate || 135)

View file

@ -38,8 +38,8 @@ export function getWeeklyActual(weekEnding: string): Promise<WeeklyActualData> {
// ── Directors Forecast API ────────────────────────────────────────────────── // ── Directors Forecast API ──────────────────────────────────────────────────
export function dfGetWorksheet(year: number, month: number): Promise<WorksheetData> { export function dfGetWorksheet(year: number, month: number, dowAlign = true): Promise<WorksheetData> {
return request<WorksheetData>(`/directors-forecast/worksheet/${year}/${month}`) return request<WorksheetData>(`/directors-forecast/worksheet/${year}/${month}?dow_align=${dowAlign}`)
} }
export function dfSaveWorksheet(year: number, month: number, data: { pickup_avg_rate: number; overrides: object[] }): Promise<{ ok: boolean }> { export function dfSaveWorksheet(year: number, month: number, data: { pickup_avg_rate: number; overrides: object[] }): Promise<{ ok: boolean }> {
@ -48,8 +48,8 @@ export function dfSaveWorksheet(year: number, month: number, data: { pickup_avg_
}) })
} }
export function dfGetReport(year: number, month: number): Promise<ForecastReportData> { export function dfGetReport(year: number, month: number, dowAlign = true): Promise<ForecastReportData> {
return request<ForecastReportData>(`/directors-forecast/report/${year}/${month}`) return request<ForecastReportData>(`/directors-forecast/report/${year}/${month}?dow_align=${dowAlign}`)
} }
export function dfSaveSnapshot(year: number, month: number, data: object) { export function dfSaveSnapshot(year: number, month: number, data: object) {

View file

@ -449,6 +449,11 @@ html, body, #root {
} }
.df-nav-btn:hover { background: var(--body-bg); } .df-nav-btn:hover { background: var(--body-bg); }
.df-month-label { font-weight: 600; font-size: 14px; min-width: 140px; text-align: center; } .df-month-label { font-weight: 600; font-size: 14px; min-width: 140px; text-align: center; }
.df-dow-toggle {
display: flex; align-items: center; gap: 6px; margin-left: auto;
font-size: 12px; color: var(--text-muted); cursor: pointer; user-select: none;
}
.df-dow-toggle input[type="checkbox"] { cursor: pointer; accent-color: var(--gold); }
.df-tabs { display: flex; gap: 4px; } .df-tabs { display: flex; gap: 4px; }
.df-tab { .df-tab {
padding: 5px 14px; border-radius: 4px; border: 1px solid var(--border); padding: 5px 14px; border-radius: 4px; border: 1px solid var(--border);

View file

@ -42,7 +42,7 @@ type DayOverride = {
// ── Worksheet tab ───────────────────────────────────────────────────────────── // ── Worksheet tab ─────────────────────────────────────────────────────────────
function WorksheetTab({ year, month }: { year: number; month: number }) { function WorksheetTab({ year, month, dowAlign }: { year: number; month: number; dowAlign: boolean }) {
const { user } = useAuth() const { user } = useAuth()
const canEdit = can(user, 'edit') const canEdit = can(user, 'edit')
@ -57,7 +57,7 @@ function WorksheetTab({ year, month }: { year: number; month: number }) {
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setError(null) setLoading(true); setError(null)
try { try {
const d: WorksheetData = await dfGetWorksheet(year, month) const d: WorksheetData = await dfGetWorksheet(year, month, dowAlign)
setData(d) setData(d)
setRate(String(d.session.pickup_avg_rate ?? 135)) setRate(String(d.session.pickup_avg_rate ?? 135))
const ovrs: Record<string, DayOverride> = {} const ovrs: Record<string, DayOverride> = {}
@ -73,7 +73,7 @@ function WorksheetTab({ year, month }: { year: number; month: number }) {
setDirty(false) setDirty(false)
} catch { setError('Failed to load worksheet data.') } } catch { setError('Failed to load worksheet data.') }
finally { setLoading(false) } finally { setLoading(false) }
}, [year, month]) }, [year, month, dowAlign])
useEffect(() => { load() }, [load]) useEffect(() => { load() }, [load])
@ -287,7 +287,7 @@ function VarCell({ a, b }: { a: number; b: number }) {
return <td className={v >= 0 ? 'df-pos' : 'df-neg'}>{v >= 0 ? '+' : ''}{fmtCcy(v)}</td> return <td className={v >= 0 ? 'df-pos' : 'df-neg'}>{v >= 0 ? '+' : ''}{fmtCcy(v)}</td>
} }
function ReportTab({ year, month }: { year: number; month: number }) { function ReportTab({ year, month, dowAlign }: { year: number; month: number; dowAlign: boolean }) {
const { user } = useAuth() const { user } = useAuth()
const canEdit = can(user, 'edit') const canEdit = can(user, 'edit')
@ -299,10 +299,10 @@ function ReportTab({ year, month }: { year: number; month: number }) {
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setError(null) setLoading(true); setError(null)
try { setData(await dfGetReport(year, month)) } try { setData(await dfGetReport(year, month, dowAlign)) }
catch { setError('Failed to load report data.') } catch { setError('Failed to load report data.') }
finally { setLoading(false) } finally { setLoading(false) }
}, [year, month]) }, [year, month, dowAlign])
useEffect(() => { load() }, [load]) useEffect(() => { load() }, [load])
@ -497,11 +497,16 @@ export default function DirectorsForecast() {
const [year, setYear] = useState(now.getFullYear()) const [year, setYear] = useState(now.getFullYear())
const [month, setMonth] = useState(now.getMonth() + 1) const [month, setMonth] = useState(now.getMonth() + 1)
const [tab, setTab] = useState<Tab>('worksheet') const [tab, setTab] = useState<Tab>('worksheet')
const [dowAlign, setDowAlign] = useState(true)
return ( return (
<div className="df-root"> <div className="df-root">
<div className="df-header"> <div className="df-header">
<MonthNav year={year} month={month} onChange={(y, m) => { setYear(y); setMonth(m) }} /> <MonthNav year={year} month={month} onChange={(y, m) => { setYear(y); setMonth(m) }} />
<label className="df-dow-toggle">
<input type="checkbox" checked={dowAlign} onChange={e => setDowAlign(e.target.checked)} />
DOW-aligned LY
</label>
<div className="df-tabs"> <div className="df-tabs">
<button className={`df-tab${tab === 'worksheet' ? ' active' : ''}`} onClick={() => setTab('worksheet')}> <button className={`df-tab${tab === 'worksheet' ? ' active' : ''}`} onClick={() => setTab('worksheet')}>
Worksheet Worksheet
@ -513,8 +518,8 @@ export default function DirectorsForecast() {
</div> </div>
{tab === 'worksheet' {tab === 'worksheet'
? <WorksheetTab key={`ws-${year}-${month}`} year={year} month={month} /> ? <WorksheetTab key={`ws-${year}-${month}-${dowAlign}`} year={year} month={month} dowAlign={dowAlign} />
: <ReportTab key={`rp-${year}-${month}`} year={year} month={month} />} : <ReportTab key={`rp-${year}-${month}-${dowAlign}`} year={year} month={month} dowAlign={dowAlign} />}
</div> </div>
) )
} }