Dashboard — Finance (P&L · cashflow)
A finance dashboard for the fictional Northwind Beverage Co. built with plain HTML, CSS and JavaScript. A KPI row tracks revenue, expenses, net profit and cash balance with up or down deltas and tiny inline-SVG sparklines, while a profit-and-loss waterfall and a cashflow area chart are drawn entirely in SVG. A budget-versus-actual progress list flags overspend, a category-badged transactions table colors inflows and outflows, and a month, quarter or year selector recomputes every KPI, redraws both charts and refilters the table with formatted currency.
MCP
Code
:root {
--brand: #5b5bf0;
--brand-d: #4646d6;
--brand-700: #3a3ab8;
--brand-50: #eef0ff;
--accent: #00b4a6;
--accent-soft: #d8f5f2;
--ink: #101322;
--ink-2: #3a4060;
--muted: #6c7393;
--bg: #f6f7fb;
--white: #ffffff;
--surface: #ffffff;
--line: rgba(16, 19, 34, 0.1);
--line-2: rgba(16, 19, 34, 0.16);
--ok: #2f9e6f;
--warn: #d98a2b;
--danger: #d4503e;
--r-sm: 8px;
--r-md: 14px;
--r-lg: 20px;
--sh-1: 0 1px 2px rgba(16, 19, 34, 0.08);
--sh-2: 0 8px 24px rgba(16, 19, 34, 0.08);
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
font-family: "Inter", system-ui, -apple-system, sans-serif;
font-size: 14px;
line-height: 1.5;
color: var(--ink);
background: var(--bg);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1, h2 { margin: 0; line-height: 1.2; letter-spacing: -0.01em; }
a { color: inherit; text-decoration: none; }
:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 2px;
border-radius: 6px;
}
.card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-md);
box-shadow: var(--sh-1);
}
/* ---------- Shell ---------- */
.shell {
display: grid;
grid-template-columns: 248px 1fr;
min-height: 100vh;
}
.backdrop {
position: fixed;
inset: 0;
background: rgba(16, 19, 34, 0.42);
z-index: 40;
backdrop-filter: blur(1px);
}
/* ---------- Sidebar ---------- */
.sidebar {
position: sticky;
top: 0;
align-self: start;
height: 100vh;
display: flex;
flex-direction: column;
gap: 8px;
padding: 20px 16px;
background: var(--white);
border-right: 1px solid var(--line);
}
.brand {
display: flex;
align-items: center;
gap: 12px;
padding: 4px 8px 16px;
}
.brand-mark {
display: grid;
place-items: center;
width: 38px;
height: 38px;
border-radius: var(--r-sm);
background: linear-gradient(135deg, var(--brand), var(--brand-700));
color: #fff;
font-size: 20px;
font-weight: 700;
box-shadow: var(--sh-1);
}
.brand-text { display: flex; flex-direction: column; line-height: 1.1; }
.brand-text strong { font-size: 16px; font-weight: 700; }
.brand-text span { font-size: 12px; color: var(--muted); }
.nav-list { list-style: none; margin: 0; padding: 4px 0; display: flex; flex-direction: column; gap: 2px; flex: 1; }
.nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-radius: var(--r-sm);
color: var(--ink-2);
font-weight: 500;
transition: background 0.15s, color 0.15s;
}
.nav-item:hover { background: var(--bg); color: var(--ink); }
.nav-item.is-active { background: var(--brand-50); color: var(--brand-d); font-weight: 600; }
.ni-ic { width: 18px; text-align: center; font-size: 15px; opacity: 0.85; }
.ni-badge {
margin-left: auto;
background: var(--brand);
color: #fff;
font-size: 11px;
font-weight: 600;
padding: 1px 7px;
border-radius: 999px;
}
.nav-foot { padding-top: 8px; }
.org-card {
display: flex;
align-items: center;
gap: 10px;
padding: 12px;
border: 1px solid var(--line);
border-radius: var(--r-md);
background: var(--bg);
}
.org-card strong { display: block; font-size: 13px; }
.org-card span { font-size: 11.5px; color: var(--muted); }
.org-dot {
width: 9px; height: 9px; border-radius: 50%;
background: var(--ok);
box-shadow: 0 0 0 4px rgba(47, 158, 111, 0.16);
flex: none;
}
/* ---------- Content ---------- */
.content { display: flex; flex-direction: column; min-width: 0; }
.topbar {
position: sticky;
top: 0;
z-index: 20;
display: flex;
align-items: center;
gap: 16px;
padding: 16px 28px;
background: rgba(246, 247, 251, 0.86);
backdrop-filter: blur(8px);
border-bottom: 1px solid var(--line);
}
.page-head h1 { font-size: 22px; font-weight: 700; }
.page-head .sub { margin: 2px 0 0; font-size: 12.5px; color: var(--muted); }
.toolbar { margin-left: auto; display: flex; align-items: center; gap: 12px; }
.seg {
display: inline-flex;
background: var(--white);
border: 1px solid var(--line);
border-radius: 999px;
padding: 3px;
box-shadow: var(--sh-1);
}
.seg-btn {
border: 0;
background: transparent;
font: inherit;
font-weight: 600;
font-size: 13px;
color: var(--muted);
padding: 6px 16px;
border-radius: 999px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.seg-btn:hover { color: var(--ink-2); }
.seg-btn.is-active { background: var(--brand); color: #fff; box-shadow: var(--sh-1); }
.ghost-btn, .icon-btn {
font: inherit;
cursor: pointer;
border: 1px solid var(--line);
background: var(--white);
color: var(--ink-2);
border-radius: var(--r-sm);
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.ghost-btn {
display: inline-flex;
align-items: center;
gap: 7px;
font-weight: 600;
font-size: 13px;
padding: 8px 14px;
}
.ghost-btn:hover { background: var(--bg); border-color: var(--line-2); color: var(--ink); }
.icon-btn {
display: none;
width: 40px; height: 40px;
font-size: 20px;
place-items: center;
}
.menu-btn { display: none; }
.dots {
border: 0;
background: transparent;
color: var(--muted);
cursor: pointer;
font-size: 18px;
line-height: 1;
width: 30px; height: 30px;
border-radius: var(--r-sm);
transition: background 0.15s, color 0.15s;
}
.dots:hover { background: var(--bg); color: var(--ink); }
.main { padding: 24px 28px 40px; display: flex; flex-direction: column; gap: 20px; }
/* ---------- KPI row ---------- */
.kpis {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
.kpi { padding: 16px 18px; display: flex; flex-direction: column; gap: 10px; }
.kpi-head { display: flex; align-items: center; justify-content: space-between; }
.kpi-label { font-size: 12.5px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; }
.kpi-value { font-size: 27px; font-weight: 800; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
.kpi-foot { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.delta {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12.5px;
font-weight: 700;
padding: 3px 8px;
border-radius: 999px;
}
.delta.up { color: var(--ok); background: rgba(47, 158, 111, 0.12); }
.delta.down { color: var(--danger); background: rgba(212, 80, 62, 0.12); }
.delta .arr { font-size: 11px; }
.spark { width: 92px; height: 30px; overflow: visible; }
.spark .sk-line { fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.spark .sk-area { opacity: 0.16; }
/* ---------- Charts grid ---------- */
.grid {
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 20px;
}
.widget { display: flex; flex-direction: column; overflow: hidden; }
.widget-pnl { grid-column: span 7; }
.widget-cash { grid-column: span 5; }
.widget-budget { grid-column: span 5; }
.widget-tx { grid-column: span 7; }
.widget-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 16px 18px;
border-bottom: 1px solid var(--line);
}
.widget-head h2 { font-size: 15px; font-weight: 700; }
.widget-sub { margin: 3px 0 0; font-size: 12px; color: var(--muted); }
.widget-body { padding: 18px; flex: 1; }
/* Chart SVG */
.chart { width: 100%; height: auto; display: block; }
.pnl-chart { aspect-ratio: 640 / 280; }
.cash-chart { aspect-ratio: 640 / 240; }
.chart text { font-family: "Inter", sans-serif; }
.chart .axis-lbl { fill: var(--muted); font-size: 11px; }
.chart .bar-val { fill: var(--ink-2); font-size: 11px; font-weight: 700; }
.chart .grid-line { stroke: var(--line); stroke-width: 1; }
.chart .bar { transition: opacity 0.15s; cursor: default; }
.chart .bar:hover { opacity: 0.82; }
.legend { list-style: none; display: flex; gap: 18px; margin: 12px 2px 0; padding: 0; font-size: 12px; color: var(--ink-2); }
.legend li { display: flex; align-items: center; gap: 7px; }
.lg-dot { width: 11px; height: 11px; border-radius: 3px; display: inline-block; }
.lg-pos { background: var(--accent); }
.lg-neg { background: var(--warn); }
.lg-net { background: var(--brand); }
/* ---------- Budget list ---------- */
.budget-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 16px; }
.bg-row { display: grid; gap: 7px; }
.bg-top { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
.bg-name { font-weight: 600; font-size: 13.5px; }
.bg-fig { font-size: 12.5px; color: var(--muted); font-variant-numeric: tabular-nums; }
.bg-fig b { color: var(--ink); font-weight: 700; }
.bg-track { height: 9px; border-radius: 999px; background: var(--bg); overflow: hidden; border: 1px solid var(--line); }
.bg-fill { height: 100%; border-radius: 999px; background: var(--brand); transition: width 0.6s cubic-bezier(.22,.61,.36,1); }
.bg-fill.over { background: var(--danger); }
.bg-fill.warn { background: var(--warn); }
.bg-pct { font-size: 11.5px; font-weight: 700; }
.bg-pct.over { color: var(--danger); }
.bg-pct.warn { color: var(--warn); }
.bg-pct.ok { color: var(--ok); }
/* ---------- Transactions table ---------- */
.table-wrap { overflow-x: auto; margin: -4px -4px 0; padding: 0 4px; }
.tx-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.tx-table th {
text-align: left;
font-size: 11px;
font-weight: 700;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 8px 12px;
border-bottom: 1px solid var(--line);
}
.tx-table th.num, .tx-table td.num { text-align: right; }
.tx-table td { padding: 11px 12px; border-bottom: 1px solid var(--line); vertical-align: middle; }
.tx-table tbody tr:last-child td { border-bottom: 0; }
.tx-table tbody tr { transition: background 0.12s; }
.tx-table tbody tr:hover { background: var(--bg); }
.tx-desc { font-weight: 600; color: var(--ink); }
.tx-ref { display: block; font-size: 11.5px; color: var(--muted); font-weight: 400; }
.tx-date { color: var(--muted); font-variant-numeric: tabular-nums; }
.tx-amt { font-weight: 700; font-variant-numeric: tabular-nums; }
.tx-amt.in { color: var(--ok); }
.tx-amt.out { color: var(--danger); }
.cat {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11.5px;
font-weight: 600;
padding: 3px 9px;
border-radius: 999px;
border: 1px solid var(--line);
background: var(--bg);
color: var(--ink-2);
white-space: nowrap;
}
.cat::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--c, var(--muted)); }
.cat[data-cat="Sales"] { --c: var(--accent); }
.cat[data-cat="Payroll"] { --c: var(--brand); }
.cat[data-cat="Software"] { --c: #8a5bf0; }
.cat[data-cat="Marketing"] { --c: var(--warn); }
.cat[data-cat="Logistics"] { --c: #2b8ad9; }
.cat[data-cat="Tax"] { --c: var(--danger); }
.cat[data-cat="Interest"] { --c: var(--ok); }
.tx-filter { display: inline-flex; gap: 6px; }
.chip {
font: inherit;
font-size: 12px;
font-weight: 600;
cursor: pointer;
border: 1px solid var(--line);
background: var(--white);
color: var(--muted);
padding: 5px 11px;
border-radius: 999px;
transition: all 0.15s;
}
.chip:hover { border-color: var(--line-2); color: var(--ink-2); }
.chip.is-active { background: var(--ink); color: #fff; border-color: var(--ink); }
.empty-row td { text-align: center; color: var(--muted); padding: 28px; }
/* ---------- Toast ---------- */
.toast-host {
position: fixed;
right: 20px;
bottom: 20px;
z-index: 60;
display: flex;
flex-direction: column;
gap: 10px;
pointer-events: none;
}
.toast {
background: var(--ink);
color: #fff;
padding: 11px 16px;
border-radius: var(--r-md);
font-size: 13px;
font-weight: 500;
box-shadow: var(--sh-2);
opacity: 0;
transform: translateY(8px);
transition: opacity 0.2s, transform 0.2s;
}
.toast.show { opacity: 1; transform: translateY(0); }
/* ---------- Responsive ---------- */
@media (max-width: 1100px) {
.kpis { grid-template-columns: repeat(2, 1fr); }
.widget-pnl, .widget-cash, .widget-budget, .widget-tx { grid-column: span 12; }
}
@media (max-width: 720px) {
.shell { grid-template-columns: 1fr; }
.sidebar {
position: fixed;
top: 0; left: 0;
width: 268px;
z-index: 50;
transform: translateX(-104%);
transition: transform 0.25s ease;
box-shadow: var(--sh-2);
}
.sidebar.is-open { transform: translateX(0); }
.icon-btn, .menu-btn { display: grid; }
.topbar { padding: 12px 16px; gap: 12px; }
.page-head h1 { font-size: 19px; }
.toolbar { width: 100%; order: 3; flex-wrap: wrap; }
.main { padding: 16px 16px 32px; }
.grid { gap: 16px; }
}
@media (max-width: 460px) {
.kpis { grid-template-columns: 1fr; }
.seg-btn { padding: 6px 12px; }
.toolbar { gap: 8px; }
.ghost-btn span { display: none; }
}
@media (prefers-reduced-motion: reduce) {
* { transition-duration: 0.01ms !important; }
}(function () {
"use strict";
/* ---------------- Data (fictional: Northwind Beverage Co.) ---------------- */
// Each period carries KPI figures, prior-period values for deltas, a P&L
// waterfall breakdown, a cashflow series, budgets, and transactions.
var DATA = {
month: {
label: "Jun 2026",
kpi: {
revenue: { value: 482300, prev: 451900 },
expenses: { value: 351700, prev: 339400 },
net: { value: 130600, prev: 112500 },
cash: { value: 918450, prev: 871200 }
},
sparks: {
revenue: [38, 41, 39, 44, 46, 43, 48],
expenses: [33, 31, 34, 32, 36, 35, 34],
net: [9, 12, 8, 13, 11, 14, 13],
cash: [82, 85, 84, 88, 87, 90, 92]
},
pnl: [
{ label: "Revenue", val: 482300, type: "in" },
{ label: "COGS", val: -188400, type: "out" },
{ label: "Payroll", val: -101200, type: "out" },
{ label: "Marketing", val: -34800, type: "out" },
{ label: "Other", val: -27300, type: "out" }
],
cash: [
{ t: "W1", v: 871200 }, { t: "W2", v: 889600 }, { t: "W3", v: 902300 },
{ t: "W4", v: 894100 }, { t: "Now", v: 918450 }
],
cashLabel: "Weekly balance · June",
budgets: [
{ name: "Cost of goods", actual: 188400, budget: 195000 },
{ name: "Payroll", actual: 101200, budget: 100000 },
{ name: "Marketing", actual: 34800, budget: 30000 },
{ name: "Software & tools", actual: 9400, budget: 14000 },
{ name: "Logistics", actual: 17900, budget: 18500 }
],
tx: [
{ desc: "Harbor Grocers — wholesale order", ref: "INV-4821", cat: "Sales", date: "Jun 12", amt: 28400, flow: "in" },
{ desc: "ColdChain Freight Inc.", ref: "BILL-2290", cat: "Logistics", date: "Jun 11", amt: -6120, flow: "out" },
{ desc: "Payroll run — bi-weekly", ref: "PAY-0612", cat: "Payroll", date: "Jun 10", amt: -50600, flow: "out" },
{ desc: "Sunset Cafe Group", ref: "INV-4815", cat: "Sales", date: "Jun 9", amt: 14250, flow: "in" },
{ desc: "Meta Ads — June flight", ref: "BILL-2284", cat: "Marketing", date: "Jun 8", amt: -8900, flow: "out" },
{ desc: "Figma + Linear seats", ref: "BILL-2280", cat: "Software", date: "Jun 6", amt: -2350, flow: "out" },
{ desc: "Metro Distributors", ref: "INV-4807", cat: "Sales", date: "Jun 5", amt: 41600, flow: "in" },
{ desc: "Quarterly sales tax remittance", ref: "TAX-Q2", cat: "Tax", date: "Jun 3", amt: -12480, flow: "out" }
]
},
quarter: {
label: "Q2 2026",
kpi: {
revenue: { value: 1418900, prev: 1326500 },
expenses: { value: 1048200, prev: 1011700 },
net: { value: 370700, prev: 314800 },
cash: { value: 918450, prev: 802300 }
},
sparks: {
revenue: [42, 45, 44, 47, 49, 46, 52],
expenses: [34, 36, 35, 38, 37, 39, 38],
net: [10, 11, 13, 12, 14, 13, 16],
cash: [72, 76, 79, 81, 84, 88, 92]
},
pnl: [
{ label: "Revenue", val: 1418900, type: "in" },
{ label: "COGS", val: -561300, type: "out" },
{ label: "Payroll", val: -298400, type: "out" },
{ label: "Marketing", val: -104600, type: "out" },
{ label: "Other", val: -83900, type: "out" }
],
cash: [
{ t: "Apr", v: 802300 }, { t: "May", v: 861400 }, { t: "Jun", v: 918450 }
],
cashLabel: "Month-end balance · Q2",
budgets: [
{ name: "Cost of goods", actual: 561300, budget: 585000 },
{ name: "Payroll", actual: 298400, budget: 300000 },
{ name: "Marketing", actual: 104600, budget: 90000 },
{ name: "Software & tools", actual: 28200, budget: 42000 },
{ name: "Logistics", actual: 55700, budget: 55000 }
],
tx: [
{ desc: "Metro Distributors — Q2 contract", ref: "INV-4630", cat: "Sales", date: "Jun 5", amt: 124800, flow: "in" },
{ desc: "Payroll — quarter total", ref: "PAY-Q2", cat: "Payroll", date: "Jun 30", amt: -298400, flow: "out" },
{ desc: "Harbor Grocers — standing order", ref: "INV-4512", cat: "Sales", date: "May 22", amt: 86200, flow: "in" },
{ desc: "ColdChain Freight — quarterly", ref: "BILL-2110", cat: "Logistics", date: "May 19", amt: -55700, flow: "out" },
{ desc: "Brand campaign — spring", ref: "BILL-2044", cat: "Marketing", date: "Apr 28", amt: -41200, flow: "out" },
{ desc: "Sunset Cafe Group — bulk", ref: "INV-4401", cat: "Sales", date: "Apr 14", amt: 63900, flow: "in" },
{ desc: "Estimated tax payment Q2", ref: "TAX-Q2", cat: "Tax", date: "Jun 15", amt: -74100, flow: "out" }
]
},
year: {
label: "FY 2026 (YTD)",
kpi: {
revenue: { value: 5236400, prev: 4612800 },
expenses: { value: 3914600, prev: 3601200 },
net: { value: 1321800, prev: 1011600 },
cash: { value: 918450, prev: 604700 }
},
sparks: {
revenue: [30, 34, 38, 41, 44, 48, 52],
expenses: [28, 30, 33, 35, 36, 38, 39],
net: [4, 6, 8, 9, 11, 13, 16],
cash: [48, 55, 62, 68, 74, 84, 92]
},
pnl: [
{ label: "Revenue", val: 5236400, type: "in" },
{ label: "COGS", val: -2094500, type: "out" },
{ label: "Payroll", val: -1118300, type: "out" },
{ label: "Marketing", val: -389200, type: "out" },
{ label: "Other", val: -312600, type: "out" }
],
cash: [
{ t: "Q1", v: 604700 }, { t: "Q2", v: 918450 }, { t: "Q3*", v: 1142000 }, { t: "Q4*", v: 1388000 }
],
cashLabel: "Quarterly balance · *forecast",
budgets: [
{ name: "Cost of goods", actual: 2094500, budget: 2150000 },
{ name: "Payroll", actual: 1118300, budget: 1120000 },
{ name: "Marketing", actual: 389200, budget: 340000 },
{ name: "Software & tools", actual: 96800, budget: 160000 },
{ name: "Logistics", actual: 215800, budget: 220000 }
],
tx: [
{ desc: "Annual supply agreement — Metro", ref: "INV-3001", cat: "Sales", date: "Feb 1", amt: 480000, flow: "in" },
{ desc: "Payroll — YTD total", ref: "PAY-YTD", cat: "Payroll", date: "Jun 30", amt: -1118300, flow: "out" },
{ desc: "Loan interest — term facility", ref: "FIN-018", cat: "Interest", date: "Jun 1", amt: -42600, flow: "out" },
{ desc: "Harbor Grocers — annual", ref: "INV-2890", cat: "Sales", date: "Jan 10", amt: 312400, flow: "in" },
{ desc: "Fleet & logistics — YTD", ref: "BILL-YTD", cat: "Logistics", date: "Jun 30", amt: -215800, flow: "out" },
{ desc: "Brand & growth spend — YTD", ref: "MKT-YTD", cat: "Marketing", date: "Jun 30", amt: -389200, flow: "out" },
{ desc: "Estimated income tax — YTD", ref: "TAX-YTD", cat: "Tax", date: "Jun 15", amt: -188400, flow: "out" }
]
}
};
/* ---------------- Helpers ---------------- */
function fmtCurrency(n) {
var abs = Math.abs(n);
var sign = n < 0 ? "-" : "";
if (abs >= 1e6) return sign + "$" + (abs / 1e6).toFixed(2).replace(/\.?0+$/, "") + "M";
if (abs >= 1e3) return sign + "$" + Math.round(abs / 1e3).toLocaleString("en-US") + "K";
return sign + "$" + Math.round(abs).toLocaleString("en-US");
}
function fmtFull(n) {
var sign = n < 0 ? "-" : n > 0 ? "+" : "";
return sign + "$" + Math.abs(Math.round(n)).toLocaleString("en-US");
}
function pctChange(now, prev) {
if (!prev) return 0;
return ((now - prev) / Math.abs(prev)) * 100;
}
var $ = function (sel, root) { return (root || document).querySelector(sel); };
var $$ = function (sel, root) { return Array.prototype.slice.call((root || document).querySelectorAll(sel)); };
/* ---------------- Toast ---------------- */
var toastHost = $("#toastHost");
function toast(msg) {
var el = document.createElement("div");
el.className = "toast";
el.textContent = msg;
toastHost.appendChild(el);
requestAnimationFrame(function () { el.classList.add("show"); });
setTimeout(function () {
el.classList.remove("show");
setTimeout(function () { el.remove(); }, 220);
}, 2600);
}
/* ---------------- State ---------------- */
var state = { period: "month", flow: "all" };
/* ---------------- Animated number ---------------- */
function animateValue(el, to) {
var from = parseFloat(el.getAttribute("data-cur")) || 0;
var start = performance.now();
var dur = 600;
function step(now) {
var p = Math.min(1, (now - start) / dur);
var eased = 1 - Math.pow(1 - p, 3);
var cur = from + (to - from) * eased;
el.textContent = fmtCurrency(cur);
if (p < 1) requestAnimationFrame(step);
else el.setAttribute("data-cur", String(to));
}
requestAnimationFrame(step);
}
/* ---------------- KPI rendering ---------------- */
// For expenses, a decrease is "good": invert delta semantics.
var KPI_GOOD_DOWN = { expenses: true };
var SPARK_COLOR = { revenue: "var(--accent)", expenses: "var(--warn)", net: "var(--brand)", cash: "var(--accent)" };
function renderKpis(d) {
Object.keys(d.kpi).forEach(function (key) {
var card = $('.kpi[data-kpi="' + key + '"]');
if (!card) return;
var k = d.kpi[key];
animateValue($('[data-field="value"]', card), k.value);
var pc = pctChange(k.value, k.prev);
var goodDown = KPI_GOOD_DOWN[key];
var positive = goodDown ? pc <= 0 : pc >= 0;
var deltaEl = $('[data-field="delta"]', card);
deltaEl.className = "delta " + (positive ? "up" : "down");
var arrow = pc >= 0 ? "▲" : "▼";
deltaEl.innerHTML = '<span class="arr" aria-hidden="true">' + arrow + "</span>" +
Math.abs(pc).toFixed(1) + "% vs prior";
renderSparkline($('[data-field="spark"]', card), d.sparks[key], SPARK_COLOR[key]);
});
}
function renderSparkline(svg, vals, color) {
var w = 120, h = 32, pad = 3;
var min = Math.min.apply(null, vals), max = Math.max.apply(null, vals);
var range = max - min || 1;
var step = (w - pad * 2) / (vals.length - 1);
var pts = vals.map(function (v, i) {
var x = pad + i * step;
var y = h - pad - ((v - min) / range) * (h - pad * 2);
return [x, y];
});
var line = pts.map(function (p, i) { return (i ? "L" : "M") + p[0].toFixed(1) + " " + p[1].toFixed(1); }).join(" ");
var area = line + " L" + pts[pts.length - 1][0].toFixed(1) + " " + h + " L" + pts[0][0].toFixed(1) + " " + h + " Z";
svg.innerHTML =
'<path class="sk-area" d="' + area + '" fill="' + color + '"></path>' +
'<path class="sk-line" d="' + line + '" stroke="' + color + '"></path>';
}
/* ---------------- P&L waterfall (inline SVG) ---------------- */
function renderPnl(d) {
var svg = $("#pnlChart");
var W = 640, H = 280, padL = 8, padR = 8, padT = 16, padB = 36;
var bars = d.pnl;
// Build cumulative waterfall: revenue starts at 0, expenses step down, last bar = net.
var running = 0;
var steps = [];
bars.forEach(function (b) {
var start = running;
running += b.val;
steps.push({ label: b.label, start: start, end: running, val: b.val, type: b.type });
});
var net = running;
steps.push({ label: "Net", start: 0, end: net, val: net, type: "net", isNet: true });
var maxV = Math.max.apply(null, steps.map(function (s) { return Math.max(s.start, s.end); }));
var minV = Math.min.apply(null, steps.map(function (s) { return Math.min(s.start, s.end); }), 0);
var span = maxV - minV || 1;
var plotH = H - padT - padB;
var plotW = W - padL - padR;
function y(v) { return padT + (maxV - v) / span * plotH; }
var n = steps.length;
var gap = 14;
var bw = (plotW - gap * (n - 1)) / n;
var parts = [];
// gridlines
for (var g = 0; g <= 4; g++) {
var gv = maxV - (span * g) / 4;
var gy = y(gv);
parts.push('<line class="grid-line" x1="' + padL + '" y1="' + gy.toFixed(1) + '" x2="' + (W - padR) + '" y2="' + gy.toFixed(1) + '"></line>');
}
var fillFor = { in: "var(--accent)", out: "var(--warn)", net: "var(--brand)" };
steps.forEach(function (s, i) {
var x = padL + i * (bw + gap);
var top = y(Math.max(s.start, s.end));
var bot = y(Math.min(s.start, s.end));
var hgt = Math.max(2, bot - top);
var fill = fillFor[s.type];
parts.push(
'<rect class="bar" x="' + x.toFixed(1) + '" y="' + top.toFixed(1) + '" width="' + bw.toFixed(1) +
'" height="' + hgt.toFixed(1) + '" rx="4" fill="' + fill + '">' +
'<title>' + s.label + ": " + fmtFull(s.val) + '</title></rect>'
);
// connector line to next bar
if (i < steps.length - 2) {
var nx = padL + (i + 1) * (bw + gap);
var cy = y(s.end);
parts.push('<line x1="' + (x + bw).toFixed(1) + '" y1="' + cy.toFixed(1) + '" x2="' + nx.toFixed(1) + '" y2="' + cy.toFixed(1) + '" stroke="var(--line-2)" stroke-width="1" stroke-dasharray="3 3"></line>');
}
// value label
parts.push('<text class="bar-val" x="' + (x + bw / 2).toFixed(1) + '" y="' + (top - 6).toFixed(1) + '" text-anchor="middle">' + fmtCurrency(s.val) + "</text>");
// axis label
parts.push('<text class="axis-lbl" x="' + (x + bw / 2).toFixed(1) + '" y="' + (H - 14) + '" text-anchor="middle">' + s.label + "</text>");
});
svg.innerHTML = parts.join("");
$("#pnlSub").textContent = "Net " + fmtFull(net) + " · margin " + (net / d.kpi.revenue.value * 100).toFixed(1) + "%";
}
/* ---------------- Cashflow area chart ---------------- */
function renderCash(d) {
var svg = $("#cashChart");
var W = 640, H = 240, padL = 12, padR = 12, padT = 18, padB = 30;
var series = d.cash;
var vals = series.map(function (p) { return p.v; });
var max = Math.max.apply(null, vals);
var min = Math.min.apply(null, vals);
var pad = (max - min) * 0.25 || max * 0.1;
max += pad; min = Math.max(0, min - pad);
var range = max - min || 1;
var plotW = W - padL - padR;
var plotH = H - padT - padB;
var step = plotW / (series.length - 1);
function y(v) { return padT + (max - v) / range * plotH; }
var pts = series.map(function (p, i) { return [padL + i * step, y(p.v)]; });
var line = pts.map(function (p, i) { return (i ? "L" : "M") + p[0].toFixed(1) + " " + p[1].toFixed(1); }).join(" ");
var area = line + " L" + pts[pts.length - 1][0].toFixed(1) + " " + (H - padB) + " L" + pts[0][0].toFixed(1) + " " + (H - padB) + " Z";
var parts = [];
parts.push(
'<defs><linearGradient id="cashGrad" x1="0" y1="0" x2="0" y2="1">' +
'<stop offset="0%" stop-color="var(--brand)" stop-opacity="0.28"></stop>' +
'<stop offset="100%" stop-color="var(--brand)" stop-opacity="0.02"></stop>' +
"</linearGradient></defs>"
);
for (var g = 0; g <= 3; g++) {
var gy = padT + (plotH * g) / 3;
parts.push('<line class="grid-line" x1="' + padL + '" y1="' + gy.toFixed(1) + '" x2="' + (W - padR) + '" y2="' + gy.toFixed(1) + '"></line>');
}
parts.push('<path d="' + area + '" fill="url(#cashGrad)"></path>');
parts.push('<path d="' + line + '" fill="none" stroke="var(--brand)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"></path>');
pts.forEach(function (p, i) {
parts.push('<circle cx="' + p[0].toFixed(1) + '" cy="' + p[1].toFixed(1) + '" r="3.5" fill="var(--white)" stroke="var(--brand)" stroke-width="2"><title>' + series[i].t + ": " + fmtFull(series[i].v) + '</title></circle>');
parts.push('<text class="axis-lbl" x="' + p[0].toFixed(1) + '" y="' + (H - 10) + '" text-anchor="middle">' + series[i].t + "</text>");
});
svg.innerHTML = parts.join("");
$("#cashSub").textContent = d.cashLabel;
}
/* ---------------- Budget vs actual ---------------- */
function renderBudgets(d) {
var list = $("#budgetList");
list.innerHTML = "";
d.budgets.forEach(function (b) {
var pct = (b.actual / b.budget) * 100;
var cls = pct > 100 ? "over" : pct > 90 ? "warn" : "ok";
var fillCls = pct > 100 ? "over" : pct > 90 ? "warn" : "";
var li = document.createElement("li");
li.className = "bg-row";
li.innerHTML =
'<div class="bg-top">' +
'<span class="bg-name">' + b.name + "</span>" +
'<span class="bg-fig"><b>' + fmtCurrency(b.actual) + "</b> / " + fmtCurrency(b.budget) + "</span>" +
"</div>" +
'<div class="bg-track"><div class="bg-fill ' + fillCls + '" style="width:0%"></div></div>' +
'<span class="bg-pct ' + cls + '">' + Math.round(pct) + "% of budget" +
(pct > 100 ? " · over by " + fmtCurrency(b.actual - b.budget) : "") + "</span>";
list.appendChild(li);
// animate fill
var fill = $(".bg-fill", li);
requestAnimationFrame(function () { fill.style.width = Math.min(100, pct).toFixed(1) + "%"; });
});
}
/* ---------------- Transactions ---------------- */
function renderTx(d) {
var body = $("#txBody");
var rows = d.tx.filter(function (t) {
return state.flow === "all" || t.flow === state.flow;
});
body.innerHTML = "";
if (!rows.length) {
body.innerHTML = '<tr class="empty-row"><td colspan="4">No ' + state.flow + 'flow transactions this period.</td></tr>';
} else {
rows.forEach(function (t) {
var tr = document.createElement("tr");
tr.innerHTML =
"<td><span class=\"tx-desc\">" + t.desc + "</span><span class=\"tx-ref\">" + t.ref + "</span></td>" +
'<td><span class="cat" data-cat="' + t.cat + '">' + t.cat + "</span></td>" +
'<td class="num tx-date">' + t.date + "</td>" +
'<td class="num tx-amt ' + t.flow + '">' + (t.amt >= 0 ? "+" : "") + fmtFull(t.amt).replace("+", "") + "</td>";
body.appendChild(tr);
});
}
$("#txCount").textContent = d.tx.length;
}
/* ---------------- Render everything ---------------- */
function renderAll() {
var d = DATA[state.period];
renderKpis(d);
renderPnl(d);
renderCash(d);
renderBudgets(d);
renderTx(d);
}
/* ---------------- Period selector ---------------- */
$$(".seg-btn").forEach(function (btn) {
btn.addEventListener("click", function () {
if (btn.classList.contains("is-active")) return;
$$(".seg-btn").forEach(function (b) {
b.classList.remove("is-active");
b.setAttribute("aria-selected", "false");
});
btn.classList.add("is-active");
btn.setAttribute("aria-selected", "true");
state.period = btn.getAttribute("data-period");
renderAll();
toast("Showing " + DATA[state.period].label);
});
});
/* ---------------- Transaction flow filter ---------------- */
$$(".tx-filter .chip").forEach(function (chip) {
chip.addEventListener("click", function () {
$$(".tx-filter .chip").forEach(function (c) { c.classList.remove("is-active"); });
chip.classList.add("is-active");
state.flow = chip.getAttribute("data-flow");
renderTx(DATA[state.period]);
});
});
/* ---------------- Export ---------------- */
$("#exportBtn").addEventListener("click", function () {
toast("Exporting " + DATA[state.period].label + " report (CSV)…");
});
/* ---------------- Off-canvas nav ---------------- */
var sidebar = $("#sidebar"), backdrop = $("#backdrop"), menuBtn = $("#menuBtn");
function openNav() {
sidebar.classList.add("is-open");
backdrop.hidden = false;
menuBtn.setAttribute("aria-expanded", "true");
}
function closeNav() {
sidebar.classList.remove("is-open");
backdrop.hidden = true;
menuBtn.setAttribute("aria-expanded", "false");
}
menuBtn.addEventListener("click", function () {
sidebar.classList.contains("is-open") ? closeNav() : openNav();
});
backdrop.addEventListener("click", closeNav);
document.addEventListener("keydown", function (e) { if (e.key === "Escape") closeNav(); });
$$(".nav-item").forEach(function (a) {
a.addEventListener("click", function (e) {
e.preventDefault();
$$(".nav-item").forEach(function (n) { n.classList.remove("is-active"); n.removeAttribute("aria-current"); });
a.classList.add("is-active");
a.setAttribute("aria-current", "page");
closeNav();
});
});
/* ---------------- Live cash tick ---------------- */
// Gently jitter the live cash balance every few seconds (month view only feel).
var reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduceMotion) {
setInterval(function () {
var card = $('.kpi[data-kpi="cash"] [data-field="value"]');
if (!card) return;
var base = DATA[state.period].kpi.cash.value;
var jitter = base * (Math.random() * 0.004 - 0.002);
card.setAttribute("data-cur", String(base + jitter));
card.textContent = fmtCurrency(base + jitter);
}, 4000);
}
/* ---------------- Init ---------------- */
renderAll();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Northwind Ledger — Finance Dashboard</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="shell">
<!-- Off-canvas backdrop -->
<div class="backdrop" id="backdrop" hidden></div>
<!-- Sidebar -->
<nav class="sidebar" id="sidebar" aria-label="Primary">
<div class="brand">
<span class="brand-mark" aria-hidden="true">₦</span>
<div class="brand-text">
<strong>Northwind</strong>
<span>Ledger</span>
</div>
</div>
<ul class="nav-list">
<li><a href="#" class="nav-item is-active" aria-current="page"><span class="ni-ic" aria-hidden="true">◧</span>Overview</a></li>
<li><a href="#" class="nav-item"><span class="ni-ic" aria-hidden="true">⊞</span>Profit & Loss</a></li>
<li><a href="#" class="nav-item"><span class="ni-ic" aria-hidden="true">◴</span>Cashflow</a></li>
<li><a href="#" class="nav-item"><span class="ni-ic" aria-hidden="true">▤</span>Budgets</a></li>
<li><a href="#" class="nav-item"><span class="ni-ic" aria-hidden="true">≡</span>Transactions</a></li>
<li><a href="#" class="nav-item"><span class="ni-ic" aria-hidden="true">◇</span>Invoices <span class="ni-badge">7</span></a></li>
</ul>
<div class="nav-foot">
<div class="org-card">
<span class="org-dot" aria-hidden="true"></span>
<div>
<strong>FY 2026</strong>
<span>Books up to date</span>
</div>
</div>
</div>
</nav>
<!-- Main -->
<div class="content">
<header class="topbar" role="banner">
<button class="icon-btn menu-btn" id="menuBtn" aria-label="Open navigation" aria-expanded="false">≡</button>
<div class="page-head">
<h1>Finance overview</h1>
<p class="sub">Northwind Beverage Co. · consolidated · USD</p>
</div>
<div class="toolbar">
<div class="seg" role="tablist" aria-label="Reporting period">
<button class="seg-btn is-active" role="tab" aria-selected="true" data-period="month">Month</button>
<button class="seg-btn" role="tab" aria-selected="false" data-period="quarter">Quarter</button>
<button class="seg-btn" role="tab" aria-selected="false" data-period="year">Year</button>
</div>
<button class="ghost-btn" id="exportBtn" type="button"><span aria-hidden="true">⤓</span> Export</button>
</div>
</header>
<main class="main" role="main">
<!-- KPI row -->
<section class="kpis" aria-label="Key financial metrics">
<article class="kpi card" data-kpi="revenue">
<header class="kpi-head">
<span class="kpi-label">Revenue</span>
<button class="dots" aria-label="Revenue options">⋯</button>
</header>
<div class="kpi-value" data-field="value">$0</div>
<div class="kpi-foot">
<span class="delta" data-field="delta"></span>
<svg class="spark" data-field="spark" viewBox="0 0 120 32" preserveAspectRatio="none" aria-hidden="true"></svg>
</div>
</article>
<article class="kpi card" data-kpi="expenses">
<header class="kpi-head">
<span class="kpi-label">Expenses</span>
<button class="dots" aria-label="Expenses options">⋯</button>
</header>
<div class="kpi-value" data-field="value">$0</div>
<div class="kpi-foot">
<span class="delta" data-field="delta"></span>
<svg class="spark" data-field="spark" viewBox="0 0 120 32" preserveAspectRatio="none" aria-hidden="true"></svg>
</div>
</article>
<article class="kpi card" data-kpi="net">
<header class="kpi-head">
<span class="kpi-label">Net profit</span>
<button class="dots" aria-label="Net profit options">⋯</button>
</header>
<div class="kpi-value" data-field="value">$0</div>
<div class="kpi-foot">
<span class="delta" data-field="delta"></span>
<svg class="spark" data-field="spark" viewBox="0 0 120 32" preserveAspectRatio="none" aria-hidden="true"></svg>
</div>
</article>
<article class="kpi card" data-kpi="cash">
<header class="kpi-head">
<span class="kpi-label">Cash balance</span>
<button class="dots" aria-label="Cash balance options">⋯</button>
</header>
<div class="kpi-value" data-field="value">$0</div>
<div class="kpi-foot">
<span class="delta" data-field="delta"></span>
<svg class="spark" data-field="spark" viewBox="0 0 120 32" preserveAspectRatio="none" aria-hidden="true"></svg>
</div>
</article>
</section>
<!-- Charts grid -->
<section class="grid" aria-label="Charts">
<article class="card widget widget-pnl">
<header class="widget-head">
<div>
<h2>Profit & loss waterfall</h2>
<p class="widget-sub" id="pnlSub">Revenue to net for the period</p>
</div>
<button class="dots" aria-label="P&L options">⋯</button>
</header>
<div class="widget-body">
<svg class="chart pnl-chart" id="pnlChart" viewBox="0 0 640 280" role="img" aria-label="Profit and loss waterfall chart"></svg>
<ul class="legend">
<li><span class="lg-dot lg-pos"></span>Inflow</li>
<li><span class="lg-dot lg-neg"></span>Outflow</li>
<li><span class="lg-dot lg-net"></span>Net</li>
</ul>
</div>
</article>
<article class="card widget widget-cash">
<header class="widget-head">
<div>
<h2>Cashflow</h2>
<p class="widget-sub" id="cashSub">Net cash movement</p>
</div>
<button class="dots" aria-label="Cashflow options">⋯</button>
</header>
<div class="widget-body">
<svg class="chart cash-chart" id="cashChart" viewBox="0 0 640 240" role="img" aria-label="Cashflow area chart"></svg>
</div>
</article>
<article class="card widget widget-budget">
<header class="widget-head">
<div>
<h2>Budget vs. actual</h2>
<p class="widget-sub">Spend against allocation</p>
</div>
<button class="dots" aria-label="Budget options">⋯</button>
</header>
<div class="widget-body">
<ul class="budget-list" id="budgetList"></ul>
</div>
</article>
<article class="card widget widget-tx">
<header class="widget-head">
<div>
<h2>Recent transactions</h2>
<p class="widget-sub"><span id="txCount">0</span> entries this period</p>
</div>
<div class="tx-filter" role="group" aria-label="Filter transactions">
<button class="chip is-active" data-flow="all">All</button>
<button class="chip" data-flow="in">Inflow</button>
<button class="chip" data-flow="out">Outflow</button>
</div>
</header>
<div class="widget-body">
<div class="table-wrap">
<table class="tx-table">
<thead>
<tr>
<th scope="col">Description</th>
<th scope="col">Category</th>
<th scope="col" class="num">Date</th>
<th scope="col" class="num">Amount</th>
</tr>
</thead>
<tbody id="txBody"></tbody>
</table>
</div>
</div>
</article>
</section>
</main>
</div>
</div>
<div class="toast-host" id="toastHost" aria-live="polite" aria-atomic="false"></div>
<script src="script.js"></script>
</body>
</html>Finance (P&L · cashflow)
A finance overview for the fictional Northwind Beverage Co. The page opens with four KPI cards — Revenue, Expenses, Net profit and Cash balance — each showing a value, an up/down delta versus the prior period colored against --ok/--danger, and a tiny inline-SVG sparkline. Expenses invert the usual semantics, so a decrease reads as good. Below sits a widget grid: a profit-and-loss waterfall (revenue stepping down through COGS, payroll, marketing and other to net), a cashflow area chart with a gradient fill and hover points, a budget-versus-actual progress list that flags overspend in amber and red, and a category-badged recent-transactions table with +/- amounts colored by flow.
Every chart is drawn with inline SVG — bars, connectors, gridlines, area paths and sparklines — with no chart library, no <canvas> and no images. KPI values count up with an eased animation, currency is formatted compactly ($482K, $1.42M) with full figures in tooltips and the table, and the cash balance gently ticks live unless prefers-reduced-motion is set.
The month / quarter / year segmented control is the core interaction: switching periods swaps the entire dataset, recomputes the KPIs and deltas, redraws both charts, re-animates the budget bars and refilters the transactions, with a toast confirming the active period. The transactions table has its own All / Inflow / Outflow chips, an Export action fires a toast, and the layout collapses to a single column with an off-canvas sidebar around 720px while staying usable down to ~360px.