reports/backend/src/routes/weekly-actual.js
jtricerolph ffa13d6e57 Restructure weekly actuals charts — Sales History section + month cumulative
Backend:
- Always fetch full calendar month (not truncated at week ending)
- Add month_daily[] to response (per-day TY/LY by dept, is_actual flag)

Frontend:
- New 'Sales History' section with:
  - Monthly dept line chart: 12-month x-axis, TY vs prior year per dept
    (Rooms/Dry/Wet solid, LY dashed — same colour per dept)
  - Rolling 12-month average trend (moved from Month Progress)
- Month Progress section gains cumulative line chart:
  - X-axis = day of month, 6 lines (3 TY solid + 3 LY dashed)
  - TY lines stop at week ending; LY spans full month

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-20 14:03:51 +00:00

305 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
// Full calendar month (needed for daily chart — month progress filters by date)
const monthStart = new Date(year, month - 1, 1)
const daysInMonth = new Date(year, month, 0).getDate()
// 36 months of raw totals needed to compute rolling 12-month averages for
// 12 display points (indices 2335) plus their LY equivalents (indices 023)
const trendMonths = []
for (let i = 35; 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`),
// Full month revenue (used for month progress totals + daily chart)
fcFetch(`/forecast/revenue?start_date=${toDateStr(monthStart)}&days=${daysInMonth}&type=all`),
// 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, fullMonthRevRaw, ...trendRevRaw] = results
const fiveWeekRevMap = buildDayMap(fiveWeekRevRaw)
const thisWeekRoomsMap = buildDayMap(thisWeekRoomsRaw)
const fullMonthRevMap = buildDayMap(fullMonthRevRaw)
// ── 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 < daysInMonth; i++) {
const d = addDays(monthStart, i)
if (d > weekEndDate) break
const dateStr = toDateStr(d)
const rev = fullMonthRevMap[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 },
},
}
// ── Month daily (for cumulative progress line chart) ──────────────────────
const weekEndStr = toDateStr(weekEndDate)
const month_daily = []
for (let i = 0; i < daysInMonth; i++) {
const d = addDays(monthStart, i)
const dateStr = toDateStr(d)
const rev = fullMonthRevMap[dateStr] || {}
month_daily.push({
day: i + 1,
date: dateStr,
ty_rooms: parseFloat(rev.accom?.otb ?? 0),
ty_dry: parseFloat(rev.dry?.otb ?? 0),
ty_wet: parseFloat(rev.wet?.otb ?? 0),
ly_rooms: parseFloat(rev.accom?.prior_final ?? 0),
ly_dry: parseFloat(rev.dry?.prior_final ?? 0),
ly_wet: parseFloat(rev.wet?.prior_final ?? 0),
is_actual: dateStr <= weekEndStr,
})
}
// ── Monthly trend (rolling 12-month average) ──────────────────────────────
// Build raw monthly totals for all 36 months
const allMonthTotals = trendMonths.map((_, i) => {
const data = trendRevRaw[i]?.data ?? []
let total = 0
for (const d of data) {
total += parseFloat(d.accom?.otb ?? 0)
+ parseFloat(d.dry?.otb ?? 0)
+ parseFloat(d.wet?.otb ?? 0)
}
return total
})
const rollingAvg = (arr, from, to) =>
arr.slice(from, to + 1).reduce((s, v) => s + v, 0) / (to - from + 1)
// Display points: indices 2335 (the last 12 months)
// TY rolling avg at index i = average of allMonthTotals[i-11 .. i]
// LY rolling avg at index i = average of allMonthTotals[i-23 .. i-12]
const monthly_trend = []
for (let i = 23; i < 36; i++) {
const tm = trendMonths[i]
monthly_trend.push({
label: `${MONTH_LABELS[tm.month - 1]}-${String(tm.year).slice(2)}`,
this_year_avg: rollingAvg(allMonthTotals, i - 11, i),
last_year_avg: rollingAvg(allMonthTotals, i - 23, i - 12),
})
}
// Monthly dept split for last 24 months (indices 1235)
// — previous 12 months followed by last 12 months
const monthly_split = []
for (let i = 12; i < 36; i++) {
const tm = trendMonths[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)
}
monthly_split.push({
label: `${MONTH_LABELS[tm.month - 1]}-${String(tm.year).slice(2)}`,
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,
month_daily,
monthly_trend,
monthly_split,
}
})
}