Hotel Reports & Forecasting
A full-screen hotel operations reporting suite with occupancy forecasting, pickup, pace, and source-mix report types. Interactive date-horizon selector, animated CSS bar/line charts showing actuals vs forecast, a live KPI summary band, and an export-to-CSV/PDF action toast.
MCP
程式碼
/* ── Design tokens ─────────────────────────────────────────────────────────── */
:root {
--navy: #1a2b4a;
--navy-d: #0f1d36;
--navy-2: #2d4570;
--cream: #f7f3eb;
--cream-2: #ece5d4;
--bone: #fbf8f2;
--gold: #c9a649;
--gold-light: #e0c378;
--gold-d: #a88a2e;
--ink: #161e2c;
--ink-2: #2e3a52;
--warm-gray: #6c7280;
--line: rgba(22, 30, 44, 0.1);
--line-strong: rgba(22, 30, 44, 0.18);
--success: #4a7752;
--danger: #b34232;
--warning: #d99020;
--info: #4a6da0;
--font-display: "Cormorant Garamond", Georgia, serif;
--font-body: "Inter", system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, monospace;
--r-sm: 6px;
--r-md: 10px;
--r-lg: 16px;
--shadow-1: 0 1px 2px rgba(22, 30, 44, 0.06), 0 2px 8px rgba(22, 30, 44, 0.06);
--shadow-2: 0 12px 36px rgba(15, 29, 54, 0.16);
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html,
body {
height: 100%;
overflow: hidden;
}
body {
font-family: var(--font-body);
background: var(--cream);
color: var(--ink);
-webkit-font-smoothing: antialiased;
}
/* ── Layout ── */
.rpt {
height: 100vh;
display: grid;
grid-template-columns: 230px 1fr;
overflow: hidden;
}
/* ── Rail ── */
.rail {
background: var(--navy);
color: var(--bone);
display: flex;
flex-direction: column;
padding: 22px 16px 14px;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
padding: 0 4px 18px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.brand-mark {
width: 38px;
height: 38px;
display: grid;
place-items: center;
border-radius: var(--r-md);
background: var(--gold);
color: var(--navy-d);
font-family: var(--font-display);
font-weight: 700;
font-size: 1.35rem;
flex-shrink: 0;
}
.brand-name {
font-family: var(--font-display);
font-size: 1.1rem;
font-weight: 700;
letter-spacing: 0.01em;
}
.brand-prop {
font-size: 0.7rem;
color: var(--gold-light);
letter-spacing: 0.08em;
text-transform: uppercase;
margin-top: 2px;
}
.nav {
margin-top: 18px;
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
}
.nav-item {
font-size: 0.86rem;
font-weight: 500;
color: rgba(251, 248, 242, 0.78);
text-decoration: none;
padding: 10px 12px;
border-radius: var(--r-md);
display: flex;
align-items: center;
gap: 10px;
}
.nav-item:hover {
background: rgba(255, 255, 255, 0.06);
color: var(--bone);
}
.nav-item.is-active {
background: rgba(201, 166, 73, 0.16);
color: var(--gold-light);
font-weight: 600;
}
.nav-dot {
width: 6px;
height: 6px;
border-radius: 999px;
background: var(--gold);
flex-shrink: 0;
}
.nav-item:not(.is-active) .nav-dot {
display: none;
}
.rail-foot {
display: flex;
flex-direction: column;
gap: 2px;
padding-top: 12px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.clock {
font-family: var(--font-mono);
font-weight: 600;
font-size: 0.88rem;
color: var(--gold-light);
}
.agent {
font-size: 0.72rem;
color: rgba(251, 248, 242, 0.6);
letter-spacing: 0.04em;
}
/* ── Main ── */
.main {
display: flex;
flex-direction: column;
overflow-y: auto;
padding: 22px 28px 28px;
gap: 18px;
}
/* ── Topbar ── */
.topbar {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
flex-wrap: wrap;
}
.kicker {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--gold-d);
font-weight: 600;
}
.topbar h1 {
font-family: var(--font-display);
font-weight: 700;
font-size: 1.9rem;
letter-spacing: -0.005em;
margin-top: 2px;
}
.topbar h1 span {
color: var(--navy-2);
font-weight: 500;
}
.topbar-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
padding-top: 4px;
}
/* ── Report type tabs ── */
.report-tabs {
display: flex;
background: var(--bone);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 3px;
gap: 2px;
}
.rtab {
background: transparent;
border: none;
font-family: inherit;
font-size: 0.82rem;
font-weight: 600;
color: var(--warm-gray);
padding: 8px 13px;
border-radius: var(--r-sm);
cursor: pointer;
white-space: nowrap;
transition: background 0.15s, color 0.15s;
}
.rtab:hover {
color: var(--ink);
}
.rtab.is-active {
background: var(--navy);
color: var(--bone);
}
/* ── Horizon selector ── */
.horizon-group {
display: flex;
background: var(--bone);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 3px;
gap: 2px;
}
.hz {
background: transparent;
border: none;
font-family: var(--font-mono);
font-size: 0.78rem;
font-weight: 700;
color: var(--warm-gray);
padding: 7px 11px;
border-radius: var(--r-sm);
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.hz:hover {
color: var(--ink);
}
.hz.is-active {
background: var(--gold);
color: var(--navy-d);
}
/* ── Export buttons ── */
.export-btn {
background: var(--navy);
color: var(--bone);
border: none;
font-family: inherit;
font-size: 0.82rem;
font-weight: 600;
padding: 9px 14px;
border-radius: var(--r-md);
cursor: pointer;
transition: background 0.15s;
}
.export-btn:hover {
background: var(--navy-d);
}
.export-btn.ghost {
background: transparent;
border: 1px solid var(--line-strong);
color: var(--ink-2);
}
.export-btn.ghost:hover {
background: var(--cream-2);
border-color: var(--navy-2);
color: var(--navy-d);
}
/* ── KPI band ── */
.kpis {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px;
}
.kpi {
background: var(--bone);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 14px 16px;
position: relative;
overflow: hidden;
}
.kpi::before {
content: "";
position: absolute;
inset: 0 auto 0 0;
width: 3px;
background: var(--gold);
}
.kpi-label {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--warm-gray);
font-weight: 600;
}
.kpi-value {
font-family: var(--font-display);
font-weight: 700;
font-size: 1.85rem;
color: var(--navy-d);
margin-top: 4px;
letter-spacing: -0.01em;
font-variant-numeric: tabular-nums;
}
.kpi-value small {
font-size: 1rem;
font-weight: 600;
color: var(--warm-gray);
margin-left: 1px;
}
.kpi-trend {
font-size: 0.74rem;
font-weight: 600;
margin-top: 2px;
letter-spacing: 0.02em;
}
.kpi-trend.up {
color: var(--success);
}
.kpi-trend.down {
color: var(--danger);
}
.kpi-trend.neutral {
color: var(--warm-gray);
}
/* ── Chart panel ── */
.chart-panel {
background: var(--bone);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 18px 20px 12px;
}
.chart-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.chart-head h2 {
font-family: var(--font-display);
font-weight: 700;
font-size: 1.15rem;
letter-spacing: -0.005em;
}
.legend {
display: flex;
gap: 14px;
align-items: center;
}
.leg {
font-size: 0.76rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 6px;
color: var(--ink-2);
}
.leg::before {
content: "";
display: inline-block;
width: 12px;
height: 12px;
border-radius: 3px;
}
.leg-actual::before {
background: var(--navy-2);
}
.leg-forecast::before {
background: var(--gold);
}
/* ── Chart inner ── */
.chart-wrap {
display: flex;
gap: 8px;
align-items: flex-end;
height: 180px;
}
.chart-y {
display: flex;
flex-direction: column;
justify-content: space-between;
height: 100%;
padding-bottom: 2px;
}
.chart-y span {
font-family: var(--font-mono);
font-size: 0.68rem;
color: var(--warm-gray);
text-align: right;
white-space: nowrap;
}
.chart-bars {
flex: 1;
display: flex;
align-items: flex-end;
gap: 6px;
height: 100%;
position: relative;
border-left: 1px solid var(--line);
border-bottom: 1px solid var(--line);
padding: 6px 4px 0;
overflow: hidden;
}
/* Horizontal grid lines */
.chart-bars::before {
content: "";
position: absolute;
inset: 0;
background: repeating-linear-gradient(
to top,
transparent,
transparent calc(25% - 1px),
var(--line) calc(25% - 1px),
var(--line) 25%
);
pointer-events: none;
}
.bar-group {
flex: 1;
display: flex;
gap: 2px;
align-items: flex-end;
height: 100%;
min-width: 0;
}
.bar {
flex: 1;
border-radius: 3px 3px 0 0;
min-height: 2px;
transform-origin: bottom;
animation: barGrow 0.45s cubic-bezier(0.22, 1, 0.36, 1) both;
position: relative;
}
.bar-actual {
background: var(--navy-2);
}
.bar-forecast {
background: var(--gold);
opacity: 0.82;
}
@keyframes barGrow {
from {
transform: scaleY(0);
}
to {
transform: scaleY(1);
}
}
.chart-x {
display: flex;
gap: 6px;
padding: 6px 0 0 44px;
}
.chart-x span {
flex: 1;
text-align: center;
font-size: 0.68rem;
color: var(--warm-gray);
font-family: var(--font-mono);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── Table panel ── */
.table-panel {
background: var(--bone);
border: 1px solid var(--line);
border-radius: var(--r-md);
overflow: hidden;
}
.table-head {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 18px;
border-bottom: 1px solid var(--line);
}
.table-head h2 {
font-family: var(--font-display);
font-weight: 700;
font-size: 1.05rem;
}
.table-meta {
font-size: 0.76rem;
color: var(--warm-gray);
font-variant-numeric: tabular-nums;
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.86rem;
}
thead th {
background: var(--cream);
text-align: left;
padding: 10px 14px;
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--warm-gray);
border-bottom: 1px solid var(--line);
white-space: nowrap;
}
tbody tr {
border-bottom: 1px solid var(--line);
transition: background 0.12s;
}
tbody tr:last-child {
border-bottom: none;
}
tbody tr:hover {
background: var(--cream);
}
tbody td {
padding: 11px 14px;
color: var(--ink);
vertical-align: middle;
}
tbody td.mono {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
font-weight: 600;
}
tbody td.up {
color: var(--success);
font-weight: 600;
}
tbody td.down {
color: var(--danger);
font-weight: 600;
}
tbody td.muted {
color: var(--warm-gray);
font-style: italic;
}
.bar-inline {
height: 6px;
border-radius: 3px;
background: var(--navy-2);
display: inline-block;
vertical-align: middle;
}
.bar-inline.fc {
background: var(--gold);
}
/* ── Toast ── */
.toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
background: var(--navy-d);
color: var(--bone);
padding: 11px 22px;
border-radius: 999px;
font-size: 0.86rem;
font-weight: 600;
box-shadow: var(--shadow-2);
white-space: nowrap;
z-index: 999;
}
/* ── Responsive ── */
@media (max-width: 1200px) {
.report-tabs {
flex-wrap: wrap;
}
.kpis {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 960px) {
.rpt {
grid-template-columns: 1fr;
height: auto;
}
html,
body {
overflow: auto;
}
.rail {
flex-direction: row;
flex-wrap: wrap;
padding: 14px 16px;
gap: 12px;
align-items: center;
}
.nav {
flex-direction: row;
margin-top: 0;
flex: none;
flex-wrap: wrap;
}
.rail-foot {
border-top: none;
padding-top: 0;
flex-direction: row;
gap: 10px;
margin-left: auto;
}
.brand {
padding-bottom: 0;
border-bottom: none;
}
.topbar {
flex-direction: column;
align-items: flex-start;
}
.topbar-actions {
padding-top: 0;
}
}
@media (max-width: 560px) {
.main {
padding: 16px;
gap: 14px;
}
.kpis {
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.topbar-actions {
flex-wrap: wrap;
}
.report-tabs {
width: 100%;
}
}// ── Mock datasets ─────────────────────────────────────────────────────────────
const DATASETS = {
occupancy: {
chartTitle: "Occupancy: Actuals vs Forecast",
tableTitle: "Weekly occupancy breakdown",
yUnit: "%",
yMax: 100,
cols: ["Week", "Actual %", "Forecast %", "Variance", "Rooms sold", "RNs avail."],
kpis: [
{ label: "Avg occupancy", value: "81", unit: "%", trend: "up", delta: "▲ 3.1% vs LY" },
{ label: "Forecast peak", value: "94", unit: "%", trend: "up", delta: "Week of 22 Jun" },
{ label: "Booked RNs", value: "1,764", unit: "", trend: "up", delta: "▲ 214 vs LY" },
{ label: "Pickup (7d)", value: "+38", unit: " RNs", trend: "up", delta: "▲ vs 7d ago" },
],
weeks7: [
{ label: "9–15 Jun", actual: 81, forecast: 83, rooms: 243, avail: 300 },
{ label: "16–22 Jun", actual: 76, forecast: 80, rooms: 228, avail: 300 },
{ label: "23–29 Jun", actual: null, forecast: 88, rooms: null, avail: 300 },
{ label: "30 Jun–6 Jul", actual: null, forecast: 91, rooms: null, avail: 300 },
{ label: "7–13 Jul", actual: null, forecast: 94, rooms: null, avail: 300 },
{ label: "14–20 Jul", actual: null, forecast: 89, rooms: null, avail: 300 },
{ label: "21–27 Jul", actual: null, forecast: 85, rooms: null, avail: 300 },
],
weeks30: [
{ label: "Jun wk 1", actual: 81, forecast: 83, rooms: 243, avail: 300 },
{ label: "Jun wk 2", actual: 76, forecast: 80, rooms: 228, avail: 300 },
{ label: "Jun wk 3", actual: null, forecast: 88, rooms: null, avail: 300 },
{ label: "Jun wk 4", actual: null, forecast: 91, rooms: null, avail: 300 },
{ label: "Jul wk 1", actual: null, forecast: 94, rooms: null, avail: 300 },
{ label: "Jul wk 2", actual: null, forecast: 89, rooms: null, avail: 300 },
{ label: "Jul wk 3", actual: null, forecast: 87, rooms: null, avail: 300 },
{ label: "Jul wk 4", actual: null, forecast: 84, rooms: null, avail: 300 },
],
weeks90: [
{ label: "Jun wk 1", actual: 81, forecast: 83, rooms: 243, avail: 300 },
{ label: "Jun wk 2", actual: 76, forecast: 80, rooms: 228, avail: 300 },
{ label: "Jun wk 3", actual: null, forecast: 88, rooms: null, avail: 300 },
{ label: "Jun wk 4", actual: null, forecast: 91, rooms: null, avail: 300 },
{ label: "Jul wk 1", actual: null, forecast: 94, rooms: null, avail: 300 },
{ label: "Jul wk 2", actual: null, forecast: 89, rooms: null, avail: 300 },
{ label: "Jul wk 3", actual: null, forecast: 87, rooms: null, avail: 300 },
{ label: "Aug wk 1", actual: null, forecast: 90, rooms: null, avail: 300 },
{ label: "Aug wk 2", actual: null, forecast: 93, rooms: null, avail: 300 },
{ label: "Aug wk 3", actual: null, forecast: 88, rooms: null, avail: 300 },
{ label: "Sep wk 1", actual: null, forecast: 79, rooms: null, avail: 300 },
{ label: "Sep wk 2", actual: null, forecast: 74, rooms: null, avail: 300 },
],
},
pickup: {
chartTitle: "Pickup: Reservations booked per window",
tableTitle: "Pickup report — bookings by window",
yUnit: " RNs",
yMax: 120,
cols: ["Week", "0–7d pickup", "8–30d pickup", "31–90d pickup", "Total RNs", "vs LY"],
kpis: [
{ label: "7-day pickup", value: "38", unit: " RNs", trend: "up", delta: "▲ 12 vs LY" },
{ label: "30-day pickup", value: "142", unit: " RNs", trend: "up", delta: "▲ 29 vs LY" },
{ label: "90-day pickup", value: "318", unit: " RNs", trend: "neutral", delta: "≈ LY pace" },
{ label: "Avg lead time", value: "22", unit: " days", trend: "up", delta: "▲ 4d vs LY" },
],
weeks7: [
{ label: "9–15 Jun", a: 38, b: 62, c: 48, total: 148 },
{ label: "16–22 Jun", a: 24, b: 71, c: 53, total: 148 },
{ label: "23–29 Jun", a: 31, b: 58, c: 60, total: 149 },
{ label: "30 Jun–6 Jul", a: 18, b: 80, c: 72, total: 170 },
{ label: "7–13 Jul", a: 12, b: 88, c: 82, total: 182 },
{ label: "14–20 Jul", a: 9, b: 76, c: 78, total: 163 },
{ label: "21–27 Jul", a: 7, b: 70, c: 64, total: 141 },
],
weeks30: [
{ label: "Jun wk 1", a: 38, b: 62, c: 48, total: 148 },
{ label: "Jun wk 2", a: 24, b: 71, c: 53, total: 148 },
{ label: "Jun wk 3", a: 31, b: 58, c: 60, total: 149 },
{ label: "Jun wk 4", a: 18, b: 80, c: 72, total: 170 },
{ label: "Jul wk 1", a: 12, b: 88, c: 82, total: 182 },
{ label: "Jul wk 2", a: 9, b: 76, c: 78, total: 163 },
{ label: "Jul wk 3", a: 7, b: 70, c: 64, total: 141 },
{ label: "Jul wk 4", a: 14, b: 64, c: 55, total: 133 },
],
weeks90: [
{ label: "Jun wk 1", a: 38, b: 62, c: 48, total: 148 },
{ label: "Jun wk 2", a: 24, b: 71, c: 53, total: 148 },
{ label: "Jun wk 3", a: 31, b: 58, c: 60, total: 149 },
{ label: "Jun wk 4", a: 18, b: 80, c: 72, total: 170 },
{ label: "Jul wk 1", a: 12, b: 88, c: 82, total: 182 },
{ label: "Jul wk 2", a: 9, b: 76, c: 78, total: 163 },
{ label: "Jul wk 3", a: 7, b: 70, c: 64, total: 141 },
{ label: "Aug wk 1", a: 14, b: 64, c: 55, total: 133 },
{ label: "Aug wk 2", a: 22, b: 58, c: 70, total: 150 },
{ label: "Aug wk 3", a: 18, b: 74, c: 65, total: 157 },
{ label: "Sep wk 1", a: 6, b: 62, c: 52, total: 120 },
{ label: "Sep wk 2", a: 4, b: 54, c: 46, total: 104 },
],
},
pace: {
chartTitle: "Pace: On-the-books vs same time last year",
tableTitle: "Pace comparison — OTB vs LY",
yUnit: " RNs",
yMax: 300,
cols: ["Week", "OTB rooms", "LY rooms", "Variance", "OTB ADR", "Pace index"],
kpis: [
{ label: "OTB rooms (total)", value: "1,764", unit: "", trend: "up", delta: "▲ 214 vs LY" },
{ label: "OTB revenue", value: "€324k", unit: "", trend: "up", delta: "▲ 11.2% vs LY" },
{ label: "OTB ADR", value: "€184", unit: "", trend: "up", delta: "▲ €6.40 vs LY" },
{ label: "Pace index", value: "1.14", unit: "", trend: "up", delta: "Above LY pace" },
],
weeks7: [
{ label: "9–15 Jun", otb: 243, ly: 218, adr: 184, idx: 1.11 },
{ label: "16–22 Jun", otb: 228, ly: 201, adr: 178, idx: 1.13 },
{ label: "23–29 Jun", otb: 264, ly: 231, adr: 192, idx: 1.14 },
{ label: "30 Jun–6 Jul", otb: 273, ly: 240, adr: 196, idx: 1.14 },
{ label: "7–13 Jul", otb: 282, ly: 248, adr: 202, idx: 1.14 },
{ label: "14–20 Jul", otb: 267, ly: 234, adr: 198, idx: 1.14 },
{ label: "21–27 Jul", otb: 255, ly: 222, adr: 190, idx: 1.15 },
],
weeks30: [
{ label: "Jun wk 1", otb: 243, ly: 218, adr: 184, idx: 1.11 },
{ label: "Jun wk 2", otb: 228, ly: 201, adr: 178, idx: 1.13 },
{ label: "Jun wk 3", otb: 264, ly: 231, adr: 192, idx: 1.14 },
{ label: "Jun wk 4", otb: 273, ly: 240, adr: 196, idx: 1.14 },
{ label: "Jul wk 1", otb: 282, ly: 248, adr: 202, idx: 1.14 },
{ label: "Jul wk 2", otb: 267, ly: 234, adr: 198, idx: 1.14 },
{ label: "Jul wk 3", otb: 255, ly: 222, adr: 190, idx: 1.15 },
{ label: "Jul wk 4", otb: 240, ly: 210, adr: 186, idx: 1.14 },
],
weeks90: [
{ label: "Jun wk 1", otb: 243, ly: 218, adr: 184, idx: 1.11 },
{ label: "Jun wk 2", otb: 228, ly: 201, adr: 178, idx: 1.13 },
{ label: "Jun wk 3", otb: 264, ly: 231, adr: 192, idx: 1.14 },
{ label: "Jun wk 4", otb: 273, ly: 240, adr: 196, idx: 1.14 },
{ label: "Jul wk 1", otb: 282, ly: 248, adr: 202, idx: 1.14 },
{ label: "Jul wk 2", otb: 267, ly: 234, adr: 198, idx: 1.14 },
{ label: "Jul wk 3", otb: 255, ly: 222, adr: 190, idx: 1.15 },
{ label: "Aug wk 1", otb: 240, ly: 210, adr: 186, idx: 1.14 },
{ label: "Aug wk 2", otb: 270, ly: 236, adr: 198, idx: 1.14 },
{ label: "Aug wk 3", otb: 264, ly: 231, adr: 193, idx: 1.14 },
{ label: "Sep wk 1", otb: 237, ly: 206, adr: 181, idx: 1.15 },
{ label: "Sep wk 2", otb: 222, ly: 194, adr: 175, idx: 1.14 },
],
},
source: {
chartTitle: "Source mix: Revenue by channel",
tableTitle: "Source mix breakdown",
yUnit: "k €",
yMax: 100,
cols: ["Channel", "RNs", "Revenue", "ADR", "% of total", "vs LY"],
kpis: [
{ label: "Direct revenue", value: "41", unit: "%", trend: "up", delta: "▲ 3pp vs LY" },
{ label: "OTA share", value: "34", unit: "%", trend: "down", delta: "▼ 2pp vs LY" },
{ label: "Corp & GDS", value: "18", unit: "%", trend: "neutral", delta: "≈ LY" },
{ label: "Channel cost avg", value: "8.4", unit: "%", trend: "down", delta: "▼ 0.6pp vs LY" },
],
channels: [
{ label: "Direct / Web", rns: 724, rev: 133216, adr: 184, pct: 41 },
{ label: "Booking.com", rns: 392, rev: 68600, adr: 175, pct: 21 },
{ label: "Expedia", rns: 210, rev: 36330, adr: 173, pct: 11 },
{ label: "Corporate GDS", rns: 298, rev: 54236, adr: 182, pct: 17 },
{ label: "Travel agent", rns: 98, rev: 18032, adr: 184, pct: 5 },
{ label: "Walk-in / OTA other", rns: 42, rev: 6930, adr: 165, pct: 3 },
{ label: "Group / MICE", rns: 0, rev: 7312, adr: 0, pct: 2 },
],
},
};
// ── State ────────────────────────────────────────────────────────────────────
let activeReport = "occupancy";
let activeDays = 7;
// ── Helpers ──────────────────────────────────────────────────────────────────
const $ = (id) => document.getElementById(id);
const toast = $("toast");
function showToast(msg) {
toast.textContent = msg;
toast.hidden = false;
clearTimeout(showToast._t);
showToast._t = setTimeout(() => (toast.hidden = true), 2200);
}
function getRows() {
const ds = DATASETS[activeReport];
if (activeReport === "source") return ds.channels;
const key = activeDays === 7 ? "weeks7" : activeDays === 30 ? "weeks30" : "weeks90";
return ds[key];
}
// ── KPI band ─────────────────────────────────────────────────────────────────
function renderKPIs() {
const ds = DATASETS[activeReport];
$("kpiBand").innerHTML = ds.kpis
.map(
(k) => `
<article class="kpi">
<p class="kpi-label">${k.label}</p>
<p class="kpi-value">${k.value}<small>${k.unit}</small></p>
<p class="kpi-trend ${k.trend}">${k.delta}</p>
</article>`
)
.join("");
}
// ── Chart ────────────────────────────────────────────────────────────────────
function renderChart() {
const ds = DATASETS[activeReport];
const rows = getRows();
$("chartTitle").textContent = ds.chartTitle;
// Y-axis labels (5 steps)
const yMax = ds.yMax;
$("chartY").innerHTML = [yMax, yMax * 0.75, yMax * 0.5, yMax * 0.25, 0]
.map((v) => `<span>${Math.round(v)}${ds.yUnit}</span>`)
.join("");
// Determine what the two bars represent per report
const barsEl = $("chartBars");
const xEl = $("chartX");
barsEl.innerHTML = "";
xEl.innerHTML = "";
rows.forEach((r) => {
let valA, valB;
if (activeReport === "occupancy") {
valA = r.actual;
valB = r.forecast;
} else if (activeReport === "pickup") {
valA = r.a;
valB = r.b + r.c;
} else if (activeReport === "pace") {
valA = r.otb;
valB = r.ly;
} else {
// source — single bar (pct)
valA = r.pct;
valB = null;
}
const aHeight = valA != null ? (valA / yMax) * 100 : 0;
const bHeight = valB != null ? (valB / yMax) * 100 : 0;
const aStyle = `height:${aHeight}%`;
const bStyle = valB != null ? `height:${bHeight}%` : "";
const group = document.createElement("div");
group.className = "bar-group";
group.innerHTML = `
<div class="bar bar-actual" style="${aStyle}" title="${r.label} · actual: ${valA}${ds.yUnit}"></div>
${valB != null ? `<div class="bar bar-forecast" style="${bStyle}" title="${r.label} · forecast: ${valB}${ds.yUnit}"></div>` : ""}`;
barsEl.appendChild(group);
const xLabel = document.createElement("span");
xLabel.textContent = r.label;
xEl.appendChild(xLabel);
});
}
// ── Table ────────────────────────────────────────────────────────────────────
function renderTable() {
const ds = DATASETS[activeReport];
const rows = getRows();
$("tableTitle").textContent = ds.tableTitle;
$("tableMeta").textContent = `${rows.length} rows`;
$("tableHead").innerHTML = `<tr>${ds.cols.map((c) => `<th>${c}</th>`).join("")}</tr>`;
let tbody = "";
if (activeReport === "occupancy") {
rows.forEach((r) => {
const variance = r.actual != null ? r.actual - r.forecast : null;
const varClass = variance == null ? "muted" : variance >= 0 ? "up" : "down";
const varText = variance == null ? "—" : (variance >= 0 ? "+" : "") + variance + "%";
const barActW = r.actual != null ? r.actual : 0;
const barFcW = r.forecast;
tbody += `<tr>
<td>${r.label}</td>
<td class="mono">${r.actual != null ? r.actual + "%" : "<span class='muted'>—</span>"}</td>
<td class="mono">${r.forecast}% <span class="bar-inline fc" style="width:${barFcW * 0.5}px"></span></td>
<td class="${varClass}">${varText}</td>
<td class="mono">${r.rooms != null ? r.rooms : "<span class='muted'>est. " + Math.round((r.forecast * r.avail) / 100) + "</span>"}</td>
<td class="mono">${r.avail}</td>
</tr>`;
});
} else if (activeReport === "pickup") {
rows.forEach((r) => {
const ly_est = Math.round(r.total * 0.88);
const varV = r.total - ly_est;
tbody += `<tr>
<td>${r.label}</td>
<td class="mono">${r.a}</td>
<td class="mono">${r.b}</td>
<td class="mono">${r.c}</td>
<td class="mono">${r.total}</td>
<td class="${varV >= 0 ? "up" : "down"}">${varV >= 0 ? "+" : ""}${varV}</td>
</tr>`;
});
} else if (activeReport === "pace") {
rows.forEach((r) => {
const diff = r.otb - r.ly;
tbody += `<tr>
<td>${r.label}</td>
<td class="mono">${r.otb}</td>
<td class="mono">${r.ly}</td>
<td class="${diff >= 0 ? "up" : "down"}">${diff >= 0 ? "+" : ""}${diff}</td>
<td class="mono">€${r.adr}</td>
<td class="mono">${r.idx.toFixed(2)}</td>
</tr>`;
});
} else {
rows.forEach((r) => {
const ly_rev = Math.round(r.rev * 0.89);
const diff = r.rev - ly_rev;
tbody += `<tr>
<td>${r.label}</td>
<td class="mono">${r.rns > 0 ? r.rns : "—"}</td>
<td class="mono">€${r.rev.toLocaleString()}</td>
<td class="mono">${r.adr > 0 ? "€" + r.adr : "—"}</td>
<td class="mono">${r.pct}% <span class="bar-inline" style="width:${r.pct}px"></span></td>
<td class="${diff >= 0 ? "up" : "down"}">${diff >= 0 ? "+" : ""}€${Math.abs(diff).toLocaleString()}</td>
</tr>`;
});
}
$("tableBody").innerHTML = tbody;
}
// ── Full render ──────────────────────────────────────────────────────────────
function render() {
renderKPIs();
renderChart();
renderTable();
}
// ── Clock ────────────────────────────────────────────────────────────────────
function tick() {
const now = new Date();
const hhmm = now.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
const wk = now.toLocaleDateString("en-GB", { weekday: "short" });
$("clock").textContent = `${hhmm} · ${wk}`;
}
tick();
setInterval(tick, 1000);
// ── Event wiring ─────────────────────────────────────────────────────────────
document.querySelectorAll(".rtab").forEach((btn) => {
btn.addEventListener("click", () => {
document.querySelectorAll(".rtab").forEach((b) => b.classList.remove("is-active"));
btn.classList.add("is-active");
activeReport = btn.dataset.report;
render();
});
});
document.querySelectorAll(".hz").forEach((btn) => {
btn.addEventListener("click", () => {
document.querySelectorAll(".hz").forEach((b) => b.classList.remove("is-active"));
btn.classList.add("is-active");
activeDays = Number(btn.dataset.days);
render();
});
});
$("btnCSV").addEventListener("click", () => {
const reportName = document.querySelector(".rtab.is-active").textContent.trim();
showToast(`Exporting "${reportName}" as CSV…`);
});
$("btnPDF").addEventListener("click", () => {
const reportName = document.querySelector(".rtab.is-active").textContent.trim();
showToast(`Generating PDF report for "${reportName}"…`);
});
// ── Init ─────────────────────────────────────────────────────────────────────
render();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700&display=swap"
/>
<link rel="stylesheet" href="style.css" />
<title>Reports & Forecasting · Aurelia Hotels</title>
</head>
<body>
<main class="rpt">
<!-- ── Rail ── -->
<aside class="rail">
<div class="brand">
<span class="brand-mark">Æ</span>
<div>
<p class="brand-name">Aurelia Hotels</p>
<p class="brand-prop">Aurelia · Madrid</p>
</div>
</div>
<nav class="nav">
<a class="nav-item" href="#">Dashboard</a>
<a class="nav-item" href="#">Reservations</a>
<a class="nav-item" href="#">Room rack</a>
<a class="nav-item" href="#">Housekeeping</a>
<a class="nav-item" href="#">Folios</a>
<a class="nav-item is-active" href="#"><span class="nav-dot"></span>Reports</a>
</nav>
<footer class="rail-foot">
<span class="clock" id="clock">--:--</span>
<span class="agent">Revenue · Ana</span>
</footer>
</aside>
<!-- ── Main content ── -->
<section class="main">
<!-- Topbar -->
<header class="topbar">
<div>
<p class="kicker">Revenue Management</p>
<h1>Reports & <span>Forecasting</span></h1>
</div>
<div class="topbar-actions">
<!-- Report type selector -->
<div class="report-tabs" role="tablist" id="reportTabs">
<button class="rtab is-active" data-report="occupancy">Occupancy forecast</button>
<button class="rtab" data-report="pickup">Pickup</button>
<button class="rtab" data-report="pace">Pace</button>
<button class="rtab" data-report="source">Source mix</button>
</div>
<!-- Horizon -->
<div class="horizon-group">
<button class="hz is-active" data-days="7">7d</button>
<button class="hz" data-days="30">30d</button>
<button class="hz" data-days="90">90d</button>
</div>
<!-- Export -->
<button class="export-btn" id="btnCSV">↓ CSV</button>
<button class="export-btn ghost" id="btnPDF">↓ PDF</button>
</div>
</header>
<!-- KPI band -->
<section class="kpis" id="kpiBand"></section>
<!-- Chart area -->
<section class="chart-panel">
<header class="chart-head">
<h2 id="chartTitle">Occupancy: Actuals vs Forecast</h2>
<div class="legend">
<span class="leg leg-actual">Actuals</span>
<span class="leg leg-forecast">Forecast</span>
</div>
</header>
<div class="chart-wrap">
<div class="chart-y" id="chartY"></div>
<div class="chart-bars" id="chartBars"></div>
</div>
<div class="chart-x" id="chartX"></div>
</section>
<!-- Data table -->
<section class="table-panel">
<header class="table-head">
<h2 id="tableTitle">Weekly breakdown</h2>
<span class="table-meta" id="tableMeta"></span>
</header>
<div class="table-wrap">
<table id="dataTable">
<thead id="tableHead"></thead>
<tbody id="tableBody"></tbody>
</table>
</div>
</section>
</section>
</main>
<div class="toast" id="toast" hidden role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Hotel Reports & Forecasting
A full-screen revenue-management reporting screen for hotel ops teams. Switch between four report types — Occupancy Forecast, Pickup, Pace, and Source Mix — each backed by its own mock dataset. The chart area redraws with a smooth bar-grow animation whenever the report type or date horizon (7 / 30 / 90 days) changes, plotting actuals in navy and forecast in gold. A KPI summary band displays the headline figures for the selected report, and a scrollable data table below shows the underlying week-by-week or segment breakdown. Export buttons at the top-right fire toast confirmations for CSV and PDF actions.