Hotel Revenue / RevPAR Dashboard
A full-screen manager revenue dashboard with KPI band (Occupancy, ADR, RevPAR, Total Revenue), a CSS-only animated bar chart of daily revenue across the week, a revenue-by-segment table, top room-types table, and a date-range / property selector that recalculates all figures.
MCP
Código
/* ── 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);
/* Ops-surface overrides */
--ops-bg: #0f1d36;
--ops-panel: #162238;
--ops-panel-2: #1e2f4a;
--ops-line: rgba(255, 255, 255, 0.08);
--ops-line-strong: rgba(255, 255, 255, 0.14);
--ops-text: rgba(251, 248, 242, 0.9);
--ops-sub: rgba(251, 248, 242, 0.5);
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: var(--font-body);
background: var(--ops-bg);
color: var(--ops-text);
-webkit-font-smoothing: antialiased;
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* ── Top bar ── */
.topbar {
display: flex;
align-items: center;
gap: 20px;
padding: 0 28px;
height: 62px;
background: var(--navy-d);
border-bottom: 1px solid var(--ops-line);
flex-shrink: 0;
flex-wrap: wrap;
}
.topbar-brand {
display: flex;
align-items: center;
gap: 12px;
text-decoration: none;
flex-shrink: 0;
}
.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.3rem;
}
.topbar-title {
display: flex;
flex-direction: column;
line-height: 1.2;
}
.brand-name {
font-family: var(--font-display);
font-size: 1.1rem;
font-weight: 700;
color: var(--bone);
}
.brand-name em {
font-style: normal;
color: var(--gold-light);
}
.topbar-sub {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.12em;
font-weight: 600;
color: var(--ops-sub);
}
.topbar-controls {
display: flex;
align-items: center;
gap: 14px;
margin-left: 28px;
flex-wrap: wrap;
}
.ctrl-group {
display: flex;
flex-direction: column;
gap: 2px;
}
.ctrl-label {
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.14em;
font-weight: 700;
color: var(--ops-sub);
}
.ctrl-select {
font-family: inherit;
font-size: 0.84rem;
font-weight: 600;
color: var(--bone);
background: var(--ops-panel-2);
border: 1px solid var(--ops-line-strong);
border-radius: var(--r-sm);
padding: 5px 10px;
outline: none;
cursor: pointer;
appearance: none;
padding-right: 24px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%236c7280'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
}
.ctrl-select:focus {
border-color: var(--gold);
}
/* Segment toggle */
.seg-toggle {
display: flex;
background: var(--ops-panel);
border: 1px solid var(--ops-line-strong);
border-radius: var(--r-sm);
overflow: hidden;
}
.seg-btn {
font-family: inherit;
font-size: 0.8rem;
font-weight: 600;
padding: 6px 14px;
background: transparent;
border: none;
color: var(--ops-sub);
cursor: pointer;
transition: all 0.15s;
}
.seg-btn.active {
background: var(--gold);
color: var(--navy-d);
}
.seg-btn:hover:not(.active) {
color: var(--ops-text);
}
/* Topbar right */
.topbar-right {
margin-left: auto;
display: flex;
align-items: center;
gap: 14px;
flex-shrink: 0;
}
.live-badge {
font-size: 0.72rem;
font-weight: 700;
color: var(--success);
letter-spacing: 0.04em;
}
.topbar-ts {
font-family: var(--font-mono);
font-size: 0.74rem;
color: var(--ops-sub);
font-variant-numeric: tabular-nums;
}
/* ── KPI band ── */
.kpi-band {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1px;
background: var(--ops-line);
border-bottom: 1px solid var(--ops-line);
flex-shrink: 0;
}
.kpi-card {
background: var(--ops-panel);
padding: 18px 22px;
display: flex;
flex-direction: column;
gap: 4px;
}
.kpi-card--accent {
background: linear-gradient(135deg, rgba(201, 166, 73, 0.14), rgba(168, 138, 46, 0.06));
}
.kpi-label {
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.16em;
font-weight: 700;
color: var(--ops-sub);
}
.kpi-val {
font-family: var(--font-mono);
font-size: 1.9rem;
font-weight: 700;
color: var(--bone);
font-variant-numeric: tabular-nums;
line-height: 1.1;
}
.kpi-card--accent .kpi-val {
color: var(--gold-light);
}
.kpi-trend {
font-size: 0.78rem;
font-weight: 700;
min-height: 1.1em;
}
.kpi-trend.up {
color: #7ec87e;
}
.kpi-trend.down {
color: #e88a7a;
}
.kpi-trend.neutral {
color: var(--ops-sub);
}
.kpi-sub {
font-size: 0.72rem;
color: var(--ops-sub);
}
/* ── Main dashboard grid ── */
.dash-main {
display: grid;
grid-template-columns: 1.5fr 1fr;
gap: 18px;
padding: 18px 22px;
flex: 1;
min-height: 0;
align-items: start;
}
.dash-left,
.dash-right {
display: flex;
flex-direction: column;
gap: 16px;
}
/* ── Shared card styles ── */
.chart-card,
.seg-card,
.table-card,
.snap-card {
background: var(--ops-panel);
border: 1px solid var(--ops-line);
border-radius: var(--r-lg);
padding: 20px 22px;
}
.card-head {
margin-bottom: 18px;
}
.card-title {
font-family: var(--font-display);
font-weight: 700;
font-size: 1.3rem;
color: var(--bone);
}
.card-sub {
font-size: 0.75rem;
color: var(--ops-sub);
margin-top: 2px;
}
/* ── Bar chart ── */
.bar-chart {
display: flex;
align-items: flex-end;
gap: 10px;
height: 180px;
padding-bottom: 0;
}
.bar-wrap {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
height: 100%;
position: relative;
}
.bar {
width: 100%;
border-radius: 4px 4px 0 0;
transition: height 0.45s cubic-bezier(0.34, 1.3, 0.64, 1);
position: relative;
cursor: default;
min-height: 4px;
}
.bar-revenue {
background: linear-gradient(180deg, var(--gold-light) 0%, var(--gold-d) 100%);
}
.bar-occupancy {
background: linear-gradient(180deg, #6a9fd0 0%, #3a6090 100%);
}
.bar:hover::after {
content: attr(data-tip);
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
background: var(--navy-d);
color: var(--bone);
font-size: 0.72rem;
font-weight: 700;
font-family: var(--font-mono);
padding: 5px 9px;
border-radius: var(--r-sm);
white-space: nowrap;
border: 1px solid var(--ops-line-strong);
z-index: 10;
font-variant-numeric: tabular-nums;
}
.chart-legend {
display: flex;
gap: 10px;
margin-top: 10px;
}
.legend-day {
flex: 1;
text-align: center;
font-size: 0.7rem;
color: var(--ops-sub);
font-weight: 600;
}
.legend-day.today {
color: var(--gold-light);
}
/* ── Revenue by segment ── */
.seg-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.seg-row {
display: flex;
flex-direction: column;
gap: 5px;
}
.seg-info {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.seg-name {
font-size: 0.84rem;
font-weight: 600;
color: var(--ops-text);
}
.seg-amount {
font-family: var(--font-mono);
font-size: 0.82rem;
font-weight: 700;
color: var(--bone);
font-variant-numeric: tabular-nums;
}
.seg-pct {
font-size: 0.72rem;
color: var(--ops-sub);
margin-left: 6px;
}
.seg-bar-track {
height: 6px;
background: var(--ops-panel-2);
border-radius: 99px;
overflow: hidden;
}
.seg-bar-fill {
height: 100%;
border-radius: 99px;
transition: width 0.45s cubic-bezier(0.34, 1.3, 0.64, 1);
}
/* Segment colours */
.fill-corporate {
background: var(--gold);
}
.fill-leisure {
background: #6a9fd0;
}
.fill-groups {
background: #8a6ab0;
}
.fill-ota {
background: #e08060;
}
.fill-direct {
background: #6aba80;
}
/* ── Room types table ── */
.rt-table {
width: 100%;
border-collapse: collapse;
font-size: 0.82rem;
}
.rt-table th {
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.12em;
font-weight: 700;
color: var(--ops-sub);
padding: 0 0 10px;
border-bottom: 1px solid var(--ops-line-strong);
text-align: left;
}
.rt-table th.num {
text-align: right;
}
.rt-table td {
padding: 10px 0;
border-bottom: 1px solid var(--ops-line);
color: var(--ops-text);
vertical-align: middle;
}
.rt-table tr:last-child td {
border-bottom: none;
}
.rt-table .num {
text-align: right;
font-family: var(--font-mono);
font-size: 0.8rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.rt-table .room-name {
font-weight: 600;
}
.rt-table .occ-good {
color: #7ec87e;
}
.rt-table .occ-mid {
color: var(--gold-light);
}
.rt-table .occ-low {
color: #e88a7a;
}
/* ── Snapshot grid ── */
.snap-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.snap-item {
background: var(--ops-panel-2);
border: 1px solid var(--ops-line);
border-radius: var(--r-md);
padding: 12px 14px;
}
.snap-label {
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.12em;
font-weight: 700;
color: var(--ops-sub);
margin-bottom: 5px;
}
.snap-val {
font-family: var(--font-mono);
font-size: 1.1rem;
font-weight: 700;
color: var(--bone);
font-variant-numeric: tabular-nums;
}
/* ── Toast ── */
.toast {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background: var(--gold);
color: var(--navy-d);
padding: 10px 22px;
border-radius: 999px;
font-size: 0.86rem;
font-weight: 700;
box-shadow: var(--shadow-2);
z-index: 50;
white-space: nowrap;
}
/* ── Responsive ── */
@media (max-width: 1080px) {
.dash-main {
grid-template-columns: 1fr;
}
.kpi-band {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 720px) {
.topbar {
height: auto;
padding: 12px 16px;
gap: 10px;
}
.topbar-controls {
margin-left: 0;
gap: 10px;
}
.topbar-right {
margin-left: 0;
width: 100%;
}
.dash-main {
padding: 12px 14px;
}
.kpi-band {
grid-template-columns: repeat(2, 1fr);
}
.kpi-val {
font-size: 1.5rem;
}
}
@media (max-width: 560px) {
.kpi-band {
grid-template-columns: 1fr 1fr;
}
.snap-grid {
grid-template-columns: 1fr 1fr;
}
.seg-toggle {
display: none;
}
}// ── Toast helper ─────────────────────────────────────────────────────────────
const toast = document.getElementById("toast");
function showToast(msg) {
toast.textContent = msg;
toast.hidden = false;
clearTimeout(showToast._t);
showToast._t = setTimeout(() => (toast.hidden = true), 2200);
}
// ── Dataset ───────────────────────────────────────────────────────────────────
// Each dataset keyed by propertyId + rangeId.
// days: label, revenue (€), occupancy (%)
// segments: name, amount (€), cssClass
// roomTypes: name, rooms, occ, adr, revenue
const DATASETS = {
"mad-w1": {
property: "Aurelia Madrid",
period: "9–15 Jun 2026",
totalRooms: 210,
days: [
{ label: "Mon 9", revenue: 41200, occupancy: 82 },
{ label: "Tue 10", revenue: 38600, occupancy: 78 },
{ label: "Wed 11", revenue: 43800, occupancy: 88 },
{ label: "Thu 12", revenue: 46200, occupancy: 91 },
{ label: "Fri 13", revenue: 51400, occupancy: 96 },
{ label: "Sat 14", revenue: 54100, occupancy: 98 },
{ label: "Sun 15", revenue: 39700, occupancy: 80 },
],
segments: [
{ name: "Corporate", amount: 108200, cls: "fill-corporate" },
{ name: "Leisure", amount: 92400, cls: "fill-leisure" },
{ name: "Groups", amount: 52600, cls: "fill-groups" },
{ name: "OTA", amount: 41800, cls: "fill-ota" },
{ name: "Direct", amount: 20000, cls: "fill-direct" },
],
roomTypes: [
{ name: "Signature Suite", rooms: 14, occ: 98, adr: 658, revenue: 64484 },
{ name: "Junior Suite", rooms: 28, occ: 95, adr: 412, revenue: 109564 },
{ name: "Deluxe Double", rooms: 80, occ: 91, adr: 248, revenue: 143416 },
{ name: "Superior Twin", rooms: 60, occ: 86, adr: 194, revenue: 100128 },
{ name: "Classic Room", rooms: 28, occ: 74, adr: 154, revenue: 30044 },
],
prevOcc: 79,
prevAdr: 262,
prevRev: 293400,
},
"mad-w2": {
property: "Aurelia Madrid",
period: "2–8 Jun 2026",
totalRooms: 210,
days: [
{ label: "Mon 2", revenue: 36200, occupancy: 74 },
{ label: "Tue 3", revenue: 34800, occupancy: 71 },
{ label: "Wed 4", revenue: 37600, occupancy: 76 },
{ label: "Thu 5", revenue: 40100, occupancy: 82 },
{ label: "Fri 6", revenue: 46000, occupancy: 90 },
{ label: "Sat 7", revenue: 48200, occupancy: 92 },
{ label: "Sun 8", revenue: 34900, occupancy: 71 },
],
segments: [
{ name: "Corporate", amount: 95600, cls: "fill-corporate" },
{ name: "Leisure", amount: 78200, cls: "fill-leisure" },
{ name: "Groups", amount: 38400, cls: "fill-groups" },
{ name: "OTA", amount: 37800, cls: "fill-ota" },
{ name: "Direct", amount: 17800, cls: "fill-direct" },
],
roomTypes: [
{ name: "Signature Suite", rooms: 14, occ: 90, adr: 640, revenue: 56448 },
{ name: "Junior Suite", rooms: 28, occ: 86, adr: 395, revenue: 94556 },
{ name: "Deluxe Double", rooms: 80, occ: 80, adr: 232, revenue: 118272 },
{ name: "Superior Twin", rooms: 60, occ: 75, adr: 180, revenue: 85050 },
{ name: "Classic Room", rooms: 28, occ: 64, adr: 146, revenue: 27453 },
],
prevOcc: 76,
prevAdr: 248,
prevRev: 258400,
},
"mad-w3": {
property: "Aurelia Madrid",
period: "26 May–1 Jun 2026",
totalRooms: 210,
days: [
{ label: "Mon 26", revenue: 32100, occupancy: 66 },
{ label: "Tue 27", revenue: 30400, occupancy: 63 },
{ label: "Wed 28", revenue: 33800, occupancy: 69 },
{ label: "Thu 29", revenue: 37200, occupancy: 76 },
{ label: "Fri 30", revenue: 43600, occupancy: 86 },
{ label: "Sat 31", revenue: 46100, occupancy: 89 },
{ label: "Sun 1", revenue: 30600, occupancy: 63 },
],
segments: [
{ name: "Corporate", amount: 88200, cls: "fill-corporate" },
{ name: "Leisure", amount: 70400, cls: "fill-leisure" },
{ name: "Groups", amount: 32000, cls: "fill-groups" },
{ name: "OTA", amount: 29600, cls: "fill-ota" },
{ name: "Direct", amount: 13600, cls: "fill-direct" },
],
roomTypes: [
{ name: "Signature Suite", rooms: 14, occ: 82, adr: 616, revenue: 47414 },
{ name: "Junior Suite", rooms: 28, occ: 79, adr: 378, revenue: 83584 },
{ name: "Deluxe Double", rooms: 80, occ: 74, adr: 218, revenue: 102466 },
{ name: "Superior Twin", rooms: 60, occ: 68, adr: 166, revenue: 67603 },
{ name: "Classic Room", rooms: 28, occ: 56, adr: 138, revenue: 20741 },
],
prevOcc: 64,
prevAdr: 228,
prevRev: 219600,
},
"bcn-w1": {
property: "Aurelia Barcelona",
period: "9–15 Jun 2026",
totalRooms: 180,
days: [
{ label: "Mon 9", revenue: 38200, occupancy: 80 },
{ label: "Tue 10", revenue: 36000, occupancy: 76 },
{ label: "Wed 11", revenue: 40400, occupancy: 86 },
{ label: "Thu 12", revenue: 43200, occupancy: 89 },
{ label: "Fri 13", revenue: 49000, occupancy: 94 },
{ label: "Sat 14", revenue: 52600, occupancy: 97 },
{ label: "Sun 15", revenue: 37400, occupancy: 78 },
],
segments: [
{ name: "Corporate", amount: 98000, cls: "fill-corporate" },
{ name: "Leisure", amount: 88400, cls: "fill-leisure" },
{ name: "Groups", amount: 48200, cls: "fill-groups" },
{ name: "OTA", amount: 44800, cls: "fill-ota" },
{ name: "Direct", amount: 17400, cls: "fill-direct" },
],
roomTypes: [
{ name: "Terrace Suite", rooms: 12, occ: 97, adr: 690, revenue: 56448 },
{ name: "Junior Suite", rooms: 24, occ: 94, adr: 430, revenue: 86640 },
{ name: "Deluxe Sea View", rooms: 60, occ: 88, adr: 262, revenue: 97354 },
{ name: "Superior Room", rooms: 58, occ: 82, adr: 196, revenue: 65774 },
{ name: "Classic Room", rooms: 26, occ: 72, adr: 162, revenue: 21258 },
],
prevOcc: 77,
prevAdr: 274,
prevRev: 270800,
},
};
// Provide fallbacks for bcn-w2, bcn-w3, lis-*, sev-* (reuse mad data with minor tweaks)
["bcn", "lis", "sev"].forEach((p) => {
["w2", "w3"].forEach((w) => {
const base = DATASETS[`mad-${w}`];
const scaleFactor = p === "bcn" ? 0.92 : p === "lis" ? 0.78 : 0.65;
DATASETS[`${p}-${w}`] = {
...base,
property: { bcn: "Aurelia Barcelona", lis: "Aurelia Lisbon", sev: "Aurelia Seville" }[p],
totalRooms: { bcn: 180, lis: 150, sev: 120 }[p],
days: base.days.map((d) => ({
...d,
revenue: Math.round(d.revenue * scaleFactor),
occupancy: Math.min(99, Math.round(d.occupancy * (scaleFactor + 0.05))),
})),
segments: base.segments.map((s) => ({
...s,
amount: Math.round(s.amount * scaleFactor),
})),
roomTypes: base.roomTypes.map((r) => ({
...r,
revenue: Math.round(r.revenue * scaleFactor),
adr: Math.round(r.adr * scaleFactor),
occ: Math.min(99, Math.round(r.occ * (scaleFactor + 0.04))),
})),
prevRev: Math.round(base.prevRev * scaleFactor),
};
});
});
["lis", "sev"].forEach((p) => {
const base = DATASETS[`bcn-w1`];
const scaleFactor = p === "lis" ? 0.78 : 0.65;
DATASETS[`${p}-w1`] = {
...base,
property: { lis: "Aurelia Lisbon", sev: "Aurelia Seville" }[p],
totalRooms: { lis: 150, sev: 120 }[p],
days: base.days.map((d) => ({
...d,
revenue: Math.round(d.revenue * scaleFactor),
occupancy: Math.min(99, Math.round(d.occupancy * (scaleFactor + 0.08))),
})),
segments: base.segments.map((s) => ({
...s,
amount: Math.round(s.amount * scaleFactor),
})),
roomTypes: base.roomTypes.map((r) => ({
...r,
revenue: Math.round(r.revenue * scaleFactor),
adr: Math.round(r.adr * scaleFactor),
occ: Math.min(99, Math.round(r.occ * scaleFactor)),
})),
prevRev: Math.round(base.prevRev * scaleFactor),
};
});
// ── State ─────────────────────────────────────────────────────────────────────
let currentMetric = "revenue"; // "revenue" | "occupancy"
function getKey() {
const prop = document.getElementById("propSelect").value;
const range = document.getElementById("rangeSelect").value;
return `${prop}-${range}`;
}
function getDataset() {
return DATASETS[getKey()] || DATASETS["mad-w1"];
}
// ── Formatters ────────────────────────────────────────────────────────────────
function fmtEuro(n) {
if (n >= 1000000) return "€" + (n / 1000000).toFixed(2) + "M";
if (n >= 1000) return "€" + (n / 1000).toFixed(0) + "k";
return "€" + n.toLocaleString("en-GB");
}
function fmtPct(n) {
return n.toFixed(1) + "%";
}
function trendArrow(val, prev, suffix) {
const diff = val - prev;
const sign = diff > 0 ? "▲" : diff < 0 ? "▼" : "–";
const cls = diff > 0 ? "up" : diff < 0 ? "down" : "neutral";
const abs = Math.abs(diff);
return { html: `${sign} ${abs}${suffix} vs prev period`, cls };
}
// ── KPI render ────────────────────────────────────────────────────────────────
function renderKPIs(ds) {
const totalRev = ds.days.reduce((s, d) => s + d.revenue, 0);
const avgOcc = ds.days.reduce((s, d) => s + d.occupancy, 0) / 7;
const roomsSold = Math.round((avgOcc / 100) * ds.totalRooms * 7);
// ADR = totalRev / roomsSold
const adr = totalRev / roomsSold;
// RevPAR = totalRev / (totalRooms * 7)
const revpar = totalRev / (ds.totalRooms * 7);
// Occupancy KPI
document.getElementById("kpiOcc").textContent = fmtPct(avgOcc);
document.getElementById("subOcc").textContent = `${roomsSold.toLocaleString()} rooms sold`;
const tOcc = trendArrow(avgOcc, ds.prevOcc, "%");
const tOccEl = document.getElementById("trendOcc");
tOccEl.textContent = tOcc.html;
tOccEl.className = "kpi-trend " + tOcc.cls;
// ADR KPI
document.getElementById("kpiAdr").textContent = "€" + Math.round(adr).toLocaleString("en-GB");
document.getElementById("subAdr").textContent = "avg daily rate";
const tAdr = trendArrow(Math.round(adr), ds.prevAdr, "€");
const tAdrEl = document.getElementById("trendAdr");
tAdrEl.textContent = tAdr.html;
tAdrEl.className = "kpi-trend " + tAdr.cls;
// RevPAR KPI
document.getElementById("kpiRevpar").textContent =
"€" + Math.round(revpar).toLocaleString("en-GB");
document.getElementById("subRevpar").textContent = "rev per avail room";
const prevRevpar = ds.prevRev / (ds.totalRooms * 7);
const tRevpar = trendArrow(Math.round(revpar), Math.round(prevRevpar), "€");
const tRevparEl = document.getElementById("trendRevpar");
tRevparEl.textContent = tRevpar.html;
tRevparEl.className = "kpi-trend " + tRevpar.cls;
// Total Revenue KPI
document.getElementById("kpiRev").textContent = fmtEuro(totalRev);
document.getElementById("subRev").textContent = "7-day period";
const tRev = trendArrow(totalRev, ds.prevRev, "€");
const tRevEl = document.getElementById("trendRev");
tRevEl.textContent = tRev.html;
tRevEl.className = "kpi-trend " + tRev.cls;
}
// ── Bar chart render ──────────────────────────────────────────────────────────
function renderChart(ds) {
const chart = document.getElementById("barChart");
const legend = document.getElementById("chartLegend");
const values = ds.days.map((d) => (currentMetric === "revenue" ? d.revenue : d.occupancy));
const maxVal = Math.max(...values);
// Update titles
document.getElementById("chartTitle").textContent =
currentMetric === "revenue" ? "Daily Revenue" : "Daily Occupancy";
document.getElementById("chartSub").textContent = `${ds.period} · ${ds.property}`;
// Build / update bars
const todayLabel = "Thu 12"; // fixed reference for "today" highlight
if (chart.children.length !== ds.days.length) {
// Initial render — create DOM nodes
chart.innerHTML = "";
legend.innerHTML = "";
ds.days.forEach((d, i) => {
const wrap = document.createElement("div");
wrap.className = "bar-wrap";
const bar = document.createElement("div");
bar.className = "bar";
bar.style.height = "4px"; // start collapsed for animation
wrap.appendChild(bar);
chart.appendChild(wrap);
const lbl = document.createElement("div");
lbl.className = "legend-day" + (d.label === todayLabel ? " today" : "");
lbl.textContent = d.label.split(" ")[0]; // just the day name
legend.appendChild(lbl);
});
}
// Animate bar heights
const bars = chart.querySelectorAll(".bar");
bars.forEach((bar, i) => {
const pct = maxVal > 0 ? (values[i] / maxVal) * 100 : 0;
const heightPx = Math.max(4, Math.round((pct / 100) * 168));
// Reset to 0 first so transition fires on re-render
bar.style.transition = "none";
bar.style.height = "4px";
bar.className = "bar " + (currentMetric === "revenue" ? "bar-revenue" : "bar-occupancy");
const tip =
currentMetric === "revenue" ? `€${values[i].toLocaleString("en-GB")}` : `${values[i]}% occ`;
bar.setAttribute("data-tip", ds.days[i].label + ": " + tip);
// Use rAF double-tap to let the reset paint before transitioning
requestAnimationFrame(() => {
requestAnimationFrame(() => {
bar.style.transition = "";
bar.style.height = heightPx + "px";
});
});
});
}
// ── Segments render ───────────────────────────────────────────────────────────
function renderSegments(ds) {
const total = ds.segments.reduce((s, seg) => s + seg.amount, 0);
const list = document.getElementById("segList");
list.innerHTML = "";
// Sort descending
const sorted = [...ds.segments].sort((a, b) => b.amount - a.amount);
sorted.forEach((seg) => {
const pct = total > 0 ? (seg.amount / total) * 100 : 0;
const row = document.createElement("div");
row.className = "seg-row";
row.innerHTML = `
<div class="seg-info">
<span class="seg-name">${seg.name}</span>
<span class="seg-amount">${fmtEuro(seg.amount)}<span class="seg-pct">${pct.toFixed(0)}%</span></span>
</div>
<div class="seg-bar-track">
<div class="seg-bar-fill ${seg.cls}" style="width:0%"></div>
</div>
`;
list.appendChild(row);
});
document.getElementById("segSub").textContent = `${ds.period} · Total ${fmtEuro(total)}`;
// Animate bar widths
requestAnimationFrame(() => {
requestAnimationFrame(() => {
list.querySelectorAll(".seg-bar-fill").forEach((fill, i) => {
const pct = total > 0 ? (sorted[i].amount / total) * 100 : 0;
fill.style.transition = "width 0.45s cubic-bezier(0.34,1.3,0.64,1)";
fill.style.width = pct.toFixed(1) + "%";
});
});
});
}
// ── Room types table ──────────────────────────────────────────────────────────
function renderRoomTypes(ds) {
const tbody = document.getElementById("rtBody");
tbody.innerHTML = "";
const sorted = [...ds.roomTypes].sort((a, b) => b.revenue - a.revenue);
sorted.forEach((rt) => {
const occClass = rt.occ >= 90 ? "occ-good" : rt.occ >= 75 ? "occ-mid" : "occ-low";
const tr = document.createElement("tr");
tr.innerHTML = `
<td class="room-name">${rt.name}</td>
<td class="num">${rt.rooms}</td>
<td class="num ${occClass}">${rt.occ}%</td>
<td class="num">€${rt.adr}</td>
<td class="num">€${rt.revenue.toLocaleString("en-GB")}</td>
`;
tbody.appendChild(tr);
});
document.getElementById("tableSubHead").textContent = `Performance · ${ds.period}`;
}
// ── Snapshot items ────────────────────────────────────────────────────────────
function renderSnap(ds) {
const totalRev = ds.days.reduce((s, d) => s + d.revenue, 0);
const avgOcc = ds.days.reduce((s, d) => s + d.occupancy, 0) / 7;
const roomsSold = Math.round((avgOcc / 100) * ds.totalRooms * 7);
const adr = totalRev / roomsSold;
const revpar = totalRev / (ds.totalRooms * 7);
const peakDay = ds.days.reduce((best, d) => (d.revenue > best.revenue ? d : best), ds.days[0]);
const peakOcc = ds.days.reduce(
(best, d) => (d.occupancy > best.occupancy ? d : best),
ds.days[0]
);
const items = [
{ label: "Total Rooms", val: ds.totalRooms },
{ label: "Rooms Sold", val: roomsSold.toLocaleString() },
{ label: "RevPAR", val: "€" + Math.round(revpar) },
{ label: "ADR", val: "€" + Math.round(adr) },
{ label: "Peak Rev Day", val: peakDay.label },
{ label: "Peak Occ Day", val: peakOcc.label },
{ label: "Prev Period Rev", val: fmtEuro(ds.prevRev) },
{
label: "Revenue Growth",
val: (((totalRev - ds.prevRev) / ds.prevRev) * 100).toFixed(1) + "%",
},
];
const grid = document.getElementById("snapGrid");
grid.innerHTML = "";
items.forEach((it) => {
const el = document.createElement("div");
el.className = "snap-item";
el.innerHTML = `<div class="snap-label">${it.label}</div><div class="snap-val">${it.val}</div>`;
grid.appendChild(el);
});
}
// ── Full render ───────────────────────────────────────────────────────────────
function renderAll() {
const ds = getDataset();
renderKPIs(ds);
renderChart(ds);
renderSegments(ds);
renderRoomTypes(ds);
renderSnap(ds);
}
// ── Controls ──────────────────────────────────────────────────────────────────
document.getElementById("propSelect").addEventListener("change", () => {
const ds = getDataset();
showToast(`Switched to ${ds.property}`);
renderAll();
});
document.getElementById("rangeSelect").addEventListener("change", () => {
const ds = getDataset();
showToast(`Period: ${ds.period}`);
renderAll();
});
document.querySelectorAll(".seg-btn").forEach((btn) => {
btn.addEventListener("click", () => {
document.querySelectorAll(".seg-btn").forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
currentMetric = btn.dataset.metric;
renderChart(getDataset());
});
});
// ── Timestamp clock ───────────────────────────────────────────────────────────
function updateClock() {
const el = document.getElementById("topbarTs");
const now = new Date();
el.textContent = now.toLocaleTimeString("en-GB", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
setInterval(updateClock, 1000);
updateClock();
// ── Init ──────────────────────────────────────────────────────────────────────
renderAll();<!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>Revenue Dashboard · Aurelia Hotels</title>
</head>
<body>
<!-- ── Top bar ── -->
<header class="topbar">
<div class="topbar-brand">
<span class="brand-mark">A</span>
<div class="topbar-title">
<span class="brand-name">Aurelia <em>Hotels</em></span>
<span class="topbar-sub">Revenue Dashboard</span>
</div>
</div>
<div class="topbar-controls">
<!-- Property selector -->
<div class="ctrl-group">
<label class="ctrl-label" for="propSelect">Property</label>
<select id="propSelect" class="ctrl-select">
<option value="mad">Madrid</option>
<option value="bcn">Barcelona</option>
<option value="lis">Lisbon</option>
<option value="sev">Seville</option>
</select>
</div>
<!-- Date range selector -->
<div class="ctrl-group">
<label class="ctrl-label" for="rangeSelect">Period</label>
<select id="rangeSelect" class="ctrl-select">
<option value="w1">Week 23 · 9–15 Jun 2026</option>
<option value="w2">Week 22 · 2–8 Jun 2026</option>
<option value="w3">Week 21 · 26 May–1 Jun 2026</option>
</select>
</div>
<!-- Chart metric toggle -->
<div class="seg-toggle" role="group" aria-label="Chart metric">
<button class="seg-btn active" data-metric="revenue">Revenue</button>
<button class="seg-btn" data-metric="occupancy">Occupancy</button>
</div>
</div>
<div class="topbar-right">
<span class="live-badge">● Live</span>
<span class="topbar-ts" id="topbarTs"></span>
</div>
</header>
<!-- ── KPI band ── -->
<section class="kpi-band" id="kpiBand">
<div class="kpi-card">
<div class="kpi-label">Occupancy</div>
<div class="kpi-val" id="kpiOcc">—</div>
<div class="kpi-trend" id="trendOcc"></div>
<div class="kpi-sub" id="subOcc">— rooms sold</div>
</div>
<div class="kpi-card">
<div class="kpi-label">ADR</div>
<div class="kpi-val" id="kpiAdr">—</div>
<div class="kpi-trend" id="trendAdr"></div>
<div class="kpi-sub" id="subAdr">avg daily rate</div>
</div>
<div class="kpi-card">
<div class="kpi-label">RevPAR</div>
<div class="kpi-val" id="kpiRevpar">—</div>
<div class="kpi-trend" id="trendRevpar"></div>
<div class="kpi-sub" id="subRevpar">rev per avail room</div>
</div>
<div class="kpi-card kpi-card--accent">
<div class="kpi-label">Total Revenue</div>
<div class="kpi-val" id="kpiRev">—</div>
<div class="kpi-trend" id="trendRev"></div>
<div class="kpi-sub" id="subRev">7-day period</div>
</div>
</section>
<!-- ── Main content ── -->
<main class="dash-main">
<!-- Left: chart + segments -->
<div class="dash-left">
<!-- Bar chart -->
<div class="chart-card">
<div class="card-head">
<div>
<h2 class="card-title" id="chartTitle">Daily Revenue</h2>
<p class="card-sub" id="chartSub">9–15 Jun 2026 · Aurelia Madrid</p>
</div>
</div>
<div class="bar-chart" id="barChart" role="img" aria-label="Bar chart of daily revenue">
<!-- Bars are injected by script.js -->
</div>
<div class="chart-legend" id="chartLegend">
<!-- Day labels injected by script.js -->
</div>
</div>
<!-- Revenue by segment -->
<div class="seg-card">
<div class="card-head">
<h2 class="card-title">Revenue by Segment</h2>
<p class="card-sub" id="segSub">Week 23 · All sources</p>
</div>
<div class="seg-list" id="segList">
<!-- Segments injected by script.js -->
</div>
</div>
</div><!-- /dash-left -->
<!-- Right: room types table -->
<div class="dash-right">
<div class="table-card">
<div class="card-head">
<h2 class="card-title">Top Room Types</h2>
<p class="card-sub" id="tableSubHead">Performance · Week 23</p>
</div>
<table class="rt-table">
<thead>
<tr>
<th>Room Type</th>
<th class="num">Rooms</th>
<th class="num">Occ%</th>
<th class="num">ADR</th>
<th class="num">Revenue</th>
</tr>
</thead>
<tbody id="rtBody">
<!-- Rows injected by script.js -->
</tbody>
</table>
</div>
<!-- Snapshot stats -->
<div class="snap-card">
<div class="card-head">
<h2 class="card-title">Snapshot</h2>
<p class="card-sub">Period at a glance</p>
</div>
<div class="snap-grid" id="snapGrid">
<!-- Items injected by script.js -->
</div>
</div>
</div><!-- /dash-right -->
</main>
<div class="toast" id="toast" hidden role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Revenue / RevPAR Dashboard
A full-screen operations dashboard styled in the shared Aurelia navy-and-gold palette. A top bar carries property and date-range selectors; below it a four-card KPI band shows Occupancy %, ADR, RevPAR, and Total Revenue each with trend arrows. The main area holds a CSS-only bar chart (divs with animated height transitions) showing daily revenue or occupancy across the selected week, a revenue-by-segment breakdown (Corporate, Leisure, Groups, OTA, Direct) with percentage bars, and a top room-types performance table. Switching the date range or property smoothly recomputes all KPIs and redraws the chart bars with a transition animation. A segment toggle filters the chart between Revenue and Occupancy views.