Add Weekly Actuals directors report with charts

Full weekly actuals report matching the spreadsheet layout:
- Week summary table (Rooms/Dry/Wet vs Budget vs Last Year vs Last Week)
- Grouped bar chart + dept split pie chart
- 5-week rolling summary table
- Occupancy table by day of week (rooms, occ%, ARR, RevPAR)
- Month progress to date (totals + dept split) with progress bar chart
- 12-month turnover trend line chart by dept
- Utilities placeholder (pending meter readings app)

Budget values come from the forecasting API which distributes monthly
budget proportionally to daily sales — so high-demand weeks (e.g.
Christmas) carry a higher budget share. Gross shown as net × 1.20.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-20 13:07:19 +00:00
parent 29a119f51d
commit b97358e9fb
9 changed files with 1433 additions and 6 deletions

View file

@ -4,6 +4,7 @@ import cors from '@fastify/cors'
import { initDb } from './db.js'
import { reportRoutes } from './routes/reports.js'
import { directorsForecastRoutes } from './routes/directors-forecast.js'
import { weeklyActualRoutes } from './routes/weekly-actual.js'
const app = Fastify({ logger: true, trustProxy: true })
const startedAt = Date.now()
@ -18,6 +19,7 @@ app.get('/health', async () => ({ status: 'healthy', version: process.env.BUILD_
await app.register(reportRoutes)
await app.register(directorsForecastRoutes)
await app.register(weeklyActualRoutes)
try {
await initDb()

View file

@ -0,0 +1,253 @@
import { requireAuth } from '../auth.js'
const FORECASTING_URL = process.env.FORECASTING_URL || 'http://10.10.10.113:3080'
async function fcFetch(path) {
const apiKey = process.env.FORECASTING_API_KEY
if (!apiKey) throw new Error('FORECASTING_API_KEY not configured')
const res = await fetch(`${FORECASTING_URL}/forecasting/api/public${path}`, {
headers: { 'X-API-Key': apiKey },
})
if (!res.ok) throw new Error(`Forecasting API ${res.status}${path}`)
return res.json()
}
function toDateStr(d) {
return d.toISOString().split('T')[0]
}
function addDays(d, n) {
const r = new Date(d)
r.setDate(r.getDate() + n)
return r
}
function buildDayMap(data) {
const m = {}
for (const d of (data?.data ?? [])) m[d.date] = d
return m
}
const DOW_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
const MONTH_LABELS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']
function weekSummary(revDayMap, weekSundayDate, weekEndingDateStr) {
let net = 0, budget = 0, ly = 0
let net_rooms = 0, net_dry = 0, net_wet = 0
let budget_rooms = 0, budget_dry = 0, budget_wet = 0
let ly_rooms = 0, ly_dry = 0, ly_wet = 0
for (let i = 0; i < 7; i++) {
const dateStr = toDateStr(addDays(weekSundayDate, i))
const rev = revDayMap[dateStr] || {}
const r = parseFloat(rev.accom?.otb ?? 0)
const d = parseFloat(rev.dry?.otb ?? 0)
const w = parseFloat(rev.wet?.otb ?? 0)
const br = parseFloat(rev.accom?.budget ?? 0)
const bd = parseFloat(rev.dry?.budget ?? 0)
const bw = parseFloat(rev.wet?.budget ?? 0)
const lr = parseFloat(rev.accom?.prior_final ?? 0)
const ld = parseFloat(rev.dry?.prior_final ?? 0)
const lw = parseFloat(rev.wet?.prior_final ?? 0)
net_rooms += r; net_dry += d; net_wet += w
budget_rooms += br; budget_dry += bd; budget_wet += bw
ly_rooms += lr; ly_dry += ld; ly_wet += lw
net += r + d + w; budget += br + bd + bw; ly += lr + ld + lw
}
return {
week_ending: weekEndingDateStr,
gross: net * 1.20,
net,
pct_to_budget: budget > 0 ? ((net - budget) / budget) * 100 : null,
budget_net: budget,
pct_to_ly: ly > 0 ? ((net - ly) / ly) * 100 : null,
ly_net: ly,
dept: {
rooms: { net: net_rooms, budget: budget_rooms, ly: ly_rooms },
dry: { net: net_dry, budget: budget_dry, ly: ly_dry },
wet: { net: net_wet, budget: budget_wet, ly: ly_wet },
},
}
}
export async function weeklyActualRoutes(fastify) {
fastify.addHook('preHandler', requireAuth)
// GET /api/weekly-actual/:date (date = week ending Saturday, YYYY-MM-DD)
fastify.get('/api/weekly-actual/:date', async (request, reply) => {
const weekEndDate = new Date(request.params.date + 'T00:00:00')
if (isNaN(weekEndDate.getTime())) return reply.status(400).send({ error: 'Invalid date' })
// Week runs Sun → Sat
const weekStartDate = addDays(weekEndDate, -6)
const year = weekEndDate.getFullYear()
const month = weekEndDate.getMonth() + 1
// 5-week window: start 4 weeks before this week's Sunday
const fiveWeekStart = addDays(weekStartDate, -28)
// Month to date: 1st of month → week ending date
const monthStart = new Date(year, month - 1, 1)
const monthDays = Math.round((weekEndDate - monthStart) / 86400000) + 1
// 12-month trend months (most recent last)
const trendMonths = []
for (let i = 11; i >= 0; i--) {
const d = new Date(year, month - 1 - i, 1)
trendMonths.push({ year: d.getFullYear(), month: d.getMonth() + 1 })
}
// All API calls in parallel
const results = await Promise.all([
// 35 days of revenue (5 × 7 weeks)
fcFetch(`/forecast/revenue?start_date=${toDateStr(fiveWeekStart)}&days=35&type=all`),
// This week rooms (for occupancy table)
fcFetch(`/forecast/rooms?start_date=${toDateStr(weekStartDate)}&days=7`),
// Month to date revenue
monthDays > 0
? fcFetch(`/forecast/revenue?start_date=${toDateStr(monthStart)}&days=${monthDays}&type=all`)
: Promise.resolve({ data: [] }),
// 12 monthly trend revenue calls
...trendMonths.map(({ year: y, month: m }) => {
const firstDay = new Date(y, m - 1, 1)
const daysInMon = new Date(y, m, 0).getDate()
return fcFetch(`/forecast/revenue?start_date=${toDateStr(firstDay)}&days=${daysInMon}&type=all`)
}),
])
const [fiveWeekRevRaw, thisWeekRoomsRaw, monthRevRaw, ...trendRevRaw] = results
const fiveWeekRevMap = buildDayMap(fiveWeekRevRaw)
const thisWeekRoomsMap = buildDayMap(thisWeekRoomsRaw)
const monthRevMap = buildDayMap(monthRevRaw)
// ── Five week summaries ────────────────────────────────────────────────────
const fiveWeeks = [0, 1, 2, 3, 4].map(w => {
const sundayDate = addDays(weekStartDate, -7 * w)
const saturdayDate = addDays(sundayDate, 6)
return weekSummary(fiveWeekRevMap, sundayDate, toDateStr(saturdayDate))
})
const thisWeek = fiveWeeks[0]
const lastWeek = fiveWeeks[1]
// ── Occupancy by DOW ──────────────────────────────────────────────────────
let totalAvail = 0, totalSold = 0, totalAccomm = 0
const daily = []
for (let i = 0; i < 7; i++) {
const d = addDays(weekStartDate, i)
const dateStr = toDateStr(d)
const rev = fiveWeekRevMap[dateStr] || {}
const rm = thisWeekRoomsMap[dateStr] || {}
const available = parseInt(rm.available_rooms ?? 0)
const rooms_sold = parseInt(rm.otb_rooms ?? 0)
const accomm = parseFloat(rev.accom?.otb ?? 0)
totalAvail += available
totalSold += rooms_sold
totalAccomm += accomm
daily.push({
date: dateStr,
dow: DOW_NAMES[d.getDay()],
available,
rooms_sold,
accomm,
arr: rooms_sold > 0 ? accomm / rooms_sold : null,
occ_pct: available > 0 ? (rooms_sold / available) * 100 : null,
revpar: available > 0 ? accomm / available : null,
})
}
const occupancy = {
daily,
totals: {
available: totalAvail,
rooms_sold: totalSold,
accomm: totalAccomm,
arr: totalSold > 0 ? totalAccomm / totalSold : null,
occ_pct: totalAvail > 0 ? (totalSold / totalAvail) * 100 : null,
revpar: totalAvail > 0 ? totalAccomm / totalAvail : null,
},
}
// ── Month progress ─────────────────────────────────────────────────────────
let mNet = { rooms: 0, dry: 0, wet: 0 }
let mBud = { rooms: 0, dry: 0, wet: 0 }
let mLy = { rooms: 0, dry: 0, wet: 0 }
for (let i = 0; i < monthDays; i++) {
const d = addDays(monthStart, i)
if (d > weekEndDate) break
const dateStr = toDateStr(d)
const rev = monthRevMap[dateStr] || {}
mNet.rooms += parseFloat(rev.accom?.otb ?? 0)
mNet.dry += parseFloat(rev.dry?.otb ?? 0)
mNet.wet += parseFloat(rev.wet?.otb ?? 0)
mBud.rooms += parseFloat(rev.accom?.budget ?? 0)
mBud.dry += parseFloat(rev.dry?.budget ?? 0)
mBud.wet += parseFloat(rev.wet?.budget ?? 0)
mLy.rooms += parseFloat(rev.accom?.prior_final ?? 0)
mLy.dry += parseFloat(rev.dry?.prior_final ?? 0)
mLy.wet += parseFloat(rev.wet?.prior_final ?? 0)
}
const mNetTotal = mNet.rooms + mNet.dry + mNet.wet
const mBudTotal = mBud.rooms + mBud.dry + mBud.wet
const mLyTotal = mLy.rooms + mLy.dry + mLy.wet
const month_progress = {
year, month,
date_to: toDateStr(weekEndDate),
gross: mNetTotal * 1.20,
net: mNetTotal,
budget_net: mBudTotal,
ly_net: mLyTotal,
pct_to_budget: mBudTotal > 0 ? ((mNetTotal - mBudTotal) / mBudTotal) * 100 : null,
pct_to_ly: mLyTotal > 0 ? ((mNetTotal - mLyTotal) / mLyTotal) * 100 : null,
split: {
rooms: { net: mNet.rooms, budget_net: mBud.rooms, ly_net: mLy.rooms,
pct_to_budget: mBud.rooms > 0 ? ((mNet.rooms - mBud.rooms) / mBud.rooms) * 100 : null,
pct_to_ly: mLy.rooms > 0 ? ((mNet.rooms - mLy.rooms) / mLy.rooms) * 100 : null },
dry: { net: mNet.dry, budget_net: mBud.dry, ly_net: mLy.dry,
pct_to_budget: mBud.dry > 0 ? ((mNet.dry - mBud.dry) / mBud.dry) * 100 : null,
pct_to_ly: mLy.dry > 0 ? ((mNet.dry - mLy.dry) / mLy.dry) * 100 : null },
wet: { net: mNet.wet, budget_net: mBud.wet, ly_net: mLy.wet,
pct_to_budget: mBud.wet > 0 ? ((mNet.wet - mBud.wet) / mBud.wet) * 100 : null,
pct_to_ly: mLy.wet > 0 ? ((mNet.wet - mLy.wet) / mLy.wet) * 100 : null },
},
}
// ── Monthly trend ─────────────────────────────────────────────────────────
const monthly_trend = trendMonths.map((tm, i) => {
const data = trendRevRaw[i]?.data ?? []
let rooms = 0, dry = 0, wet = 0
for (const d of data) {
rooms += parseFloat(d.accom?.otb ?? 0)
dry += parseFloat(d.dry?.otb ?? 0)
wet += parseFloat(d.wet?.otb ?? 0)
}
return {
label: `${MONTH_LABELS[tm.month - 1]}-${String(tm.year).slice(2)}`,
rooms, dry, wet,
total: rooms + dry + wet,
}
})
return {
week_ending: toDateStr(weekEndDate),
week_start: toDateStr(weekStartDate),
this_week: thisWeek,
last_week: lastWeek.dept,
five_weeks: fiveWeeks,
occupancy,
month_progress,
monthly_trend,
}
})
}