<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Expenses · REALM examples</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@realmorg/realm@alpha/dist/realm.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/dist/theme.css">
<script type="module" src="https://cdn.jsdelivr.net/npm/@realmorg/realm@alpha/dist/realm.js"></script>
<style>
body { margin: 0; min-height: 100svh; display: grid; place-items: start center; padding: 3rem 1rem; box-sizing: border-box; }
main { width: 100%; display: grid; justify-items: center; }
</style>
</head>
<body>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/toaster.html"></import-element>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/unicons@alpha/dist/unicons.html"></import-element>
<import-element from="./expense-tracker.html"></import-element>
<main><expense-tracker></expense-tracker></main>
<ui-toaster></ui-toaster>
</body>
</html>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/card.html"></import-element>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/chart.html"></import-element>
<import-element from="./expense-form.html"></import-element>
<import-element from="./expense-table.html"></import-element>
<custom-element name="expense-tracker">
<script type="module">
import { expenses, toggleMonth } from "./expenses.js";
function showMonth(month) {
toggleMonth(month.key);
}
</script>
<style>
:host {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: calc(var(--spacing) * 6);
width: min(calc(var(--spacing) * 300), 100%);
}
header {
display: flex;
flex-wrap: wrap;
align-items: end;
justify-content: space-between;
gap: calc(var(--spacing) * 4);
}
h1 {
margin: 0;
font-size: var(--text-2xl);
letter-spacing: -0.02em;
}
header p {
margin: calc(var(--spacing) * 1) 0 0;
color: var(--muted-foreground);
font-size: var(--text-sm);
}
.export {
display: inline-flex;
align-items: center;
gap: calc(var(--spacing) * 2);
height: calc(var(--spacing) * 9);
padding: 0 calc(var(--spacing) * 4);
border: 1px solid var(--input);
border-radius: var(--radius-md);
background: var(--background);
box-shadow: var(--shadow-xs);
color: var(--foreground);
font-size: var(--text-sm);
font-weight: var(--font-weight-medium);
text-decoration: none;
}
.export:hover {
background: var(--accent);
color: var(--accent-foreground);
}
.export:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(calc(var(--spacing) * 52), 1fr));
gap: calc(var(--spacing) * 4);
}
.value {
margin: 0;
overflow: hidden;
font-size: var(--text-2xl);
font-weight: var(--font-weight-bold);
white-space: nowrap;
text-overflow: ellipsis;
}
.layout {
display: grid;
grid-template-columns: calc(var(--spacing) * 96) minmax(0, 1fr);
align-items: start;
gap: calc(var(--spacing) * 6);
}
.side {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: calc(var(--spacing) * 6);
}
.main {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: calc(var(--spacing) * 6);
min-width: 0;
}
.months {
display: grid;
gap: calc(var(--spacing) * 1);
margin: 0;
padding: 0;
list-style: none;
}
.month {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0 calc(var(--spacing) * 3);
width: 100%;
padding: calc(var(--spacing) * 2) calc(var(--spacing) * 3);
border: 1px solid transparent;
border-radius: var(--radius-md);
background: transparent;
color: inherit;
font: inherit;
font-size: var(--text-sm);
text-align: left;
cursor: pointer;
}
.month:hover {
background: var(--accent);
}
.month:focus-visible {
outline: 2px solid var(--ring);
}
.month[aria-pressed="true"] {
border-color: var(--border);
background: var(--secondary);
}
.month small {
grid-column: 1 / -1;
color: var(--muted-foreground);
font-size: var(--text-xs);
}
@media (max-width: 1000px) {
.layout {
grid-template-columns: minmax(0, 1fr);
}
}
</style>
<header>
<div>
<h1>Expenses</h1>
<p>Track spending by category and month. Everything stays in this browser.</p>
</div>
<a class="export" download="expenses.csv" _href="#expenses.csv"><u-icon name="download"></u-icon> Export CSV</a>
</header>
<section class="stats" aria-label="Totals">
<ui-card>
<ui-card-header><ui-card-description>Total spent</ui-card-description></ui-card-header>
<ui-card-content><p class="value"><slot name="#expenses.total"></slot></p></ui-card-content>
</ui-card>
<ui-card>
<ui-card-header><ui-card-description>Average expense</ui-card-description></ui-card-header>
<ui-card-content><p class="value"><slot name="#expenses.average"></slot></p></ui-card-content>
</ui-card>
<ui-card>
<ui-card-header><ui-card-description>Largest expense</ui-card-description></ui-card-header>
<ui-card-content><p class="value"><slot name="#expenses.largest"></slot></p></ui-card-content>
</ui-card>
</section>
<div class="layout">
<div class="side">
<expense-form></expense-form>
<ui-card>
<ui-card-header>
<ui-card-title level="2">Monthly summary</ui-card-title>
<ui-card-description>Pick a month to filter the table and chart.</ui-card-description>
</ui-card-header>
<ui-card-content>
<ul class="months" aria-label="Months">
<li repeat="#expenses.months" as="month" key="key">
<button class="month" type="button" _aria-pressed="#month.current" onclick="showMonth(month)">
<strong><slot name="#month.label"></slot></strong>
<span><slot name="#month.total"></slot></span>
<small><slot name="#month.count"></slot> · <slot name="#month.change"></slot></small>
</button>
</li>
</ul>
</ui-card-content>
</ui-card>
</div>
<div class="main">
<ui-card>
<ui-card-header>
<ui-card-title level="2">By category</ui-card-title>
<ui-card-description>Spending in the current filter.</ui-card-description>
</ui-card-header>
<ui-card-content>
<ui-chart x="category" y="amount" _data="#expenses.byCategory" height="200" label="Spending by category in the current filter"></ui-chart>
</ui-card-content>
</ui-card>
<expense-table></expense-table>
</div>
</div>
</custom-element>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/button.html"></import-element>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/card.html"></import-element>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/field.html"></import-element>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/input.html"></import-element>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/label.html"></import-element>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/select.html"></import-element>
<custom-element name="expense-form">
<script type="module">
import { addExpense, CATEGORIES, today } from "./expenses.js";
const form = useState({ categories: CATEGORIES, today: today() });
function add(event) {
event.preventDefault();
addExpense(new FormData(event.target));
event.target.reset();
refs.date.value = form.today;
dispatch("toast", { title: "Expense added" });
}
</script>
<style>
:host {
display: block;
}
form {
display: grid;
gap: calc(var(--spacing) * 4);
}
.row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: calc(var(--spacing) * 4);
}
@media (max-width: 480px) {
.row {
grid-template-columns: minmax(0, 1fr);
}
}
</style>
<ui-card>
<ui-card-header>
<ui-card-title level="2">Add an expense</ui-card-title>
<ui-card-description>Every field is required.</ui-card-description>
</ui-card-header>
<ui-card-content>
<form onsubmit="add(event)">
<ui-field>
<ui-label for="expense-description">Description</ui-label>
<ui-input id="expense-description" name="description" required maxlength="60" autocomplete="off" placeholder="Groceries"></ui-input>
</ui-field>
<div class="row">
<ui-field>
<ui-label for="expense-amount">Amount (USD)</ui-label>
<ui-input id="expense-amount" name="amount" type="number" required min="0.01" step="0.01" inputmode="decimal" placeholder="0.00"></ui-input>
</ui-field>
<ui-field>
<ui-label for="expense-date">Date</ui-label>
<ui-input ref="date" id="expense-date" name="date" type="date" required _value="#form.today" _max="#form.today"></ui-input>
</ui-field>
</div>
<ui-field>
<ui-label for="expense-category">Category</ui-label>
<ui-select id="expense-category" name="category" required placeholder="Choose a category">
<option repeat="#form.categories" as="category" key="id" _value="#category.id" _label="#category.label"></option>
</ui-select>
</ui-field>
<ui-button type="submit"><u-icon name="plus"></u-icon> Add expense</ui-button>
</form>
</ui-card-content>
</ui-card>
</custom-element>
import { defineStore } from "https://cdn.jsdelivr.net/npm/@realmorg/realm@alpha/dist/realm.js";
const currency = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
const dayFormat = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
timeZone: "UTC",
});
const monthFormat = new Intl.DateTimeFormat("en-US", {
month: "long",
year: "numeric",
timeZone: "UTC",
});
export const money = (amount) => currency.format(amount);
const asDate = (iso) => new Date(`${iso}T00:00:00Z`);
export const CATEGORIES = [
{ id: "food", label: "Food" },
{ id: "housing", label: "Housing" },
{ id: "transport", label: "Transport" },
{ id: "fun", label: "Fun" },
{ id: "health", label: "Health" },
{ id: "other", label: "Other" },
];
const LABELS = Object.fromEntries(CATEGORIES.map((category) => [category.id, category.label]));
const seed = () =>
[
["2026-08-01", "Rent", "housing", 1450],
["2026-08-04", "Groceries", "food", 86.4],
["2026-08-12", "Train pass", "transport", 72],
["2026-08-19", "Concert tickets", "fun", 95],
["2026-09-01", "Rent", "housing", 1450],
["2026-09-06", "Groceries", "food", 112.15],
["2026-09-14", "Pharmacy", "health", 23.9],
["2026-09-22", "Dinner out", "food", 64.5],
["2026-10-01", "Rent", "housing", 1450],
["2026-10-03", "Groceries", "food", 94.2],
["2026-10-08", "Bike repair", "transport", 48],
["2026-10-11", "Cinema", "fun", 28],
].map(([date, description, category, amount], index) => ({
id: `seed-${index}`,
date,
description,
category,
amount,
}));
const COMPARE = {
date: (a, b) => a.date.localeCompare(b.date),
description: (a, b) => a.description.localeCompare(b.description),
category: (a, b) => LABELS[a.category].localeCompare(LABELS[b.category]),
amount: (a, b) => a.amount - b.amount,
};
export const COLUMNS = [
{ key: "date", label: "Date" },
{ key: "description", label: "Description" },
{ key: "category", label: "Category" },
{ key: "amount", label: "Amount" },
];
const SORT_NAMES = { asc: "ascending", desc: "descending" };
const cell = (value) => {
const text = String(value);
return /[",\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
};
export const toCsv = (rows) =>
[
["Date", "Description", "Category", "Amount"],
...rows.map((row) => [row.date, row.description, row.categoryLabel, row.amount.toFixed(2)]),
]
.map((line) => line.map(cell).join(","))
.join("\n");
const sum = (items) => items.reduce((total, item) => total + item.amount, 0);
export const expenses = defineStore(
"example-expenses",
{
items: seed(),
category: "all",
month: "all",
query: "",
sortKey: "date",
sortDirection: "desc",
get filtered() {
const query = this.query.trim().toLowerCase();
return this.items.filter(
(item) =>
(this.category === "all" || item.category === this.category) &&
(this.month === "all" || item.date.startsWith(this.month)) &&
item.description.toLowerCase().includes(query),
);
},
get rows() {
const sign = this.sortDirection === "asc" ? 1 : -1;
return this.filtered
.toSorted((a, b) => COMPARE[this.sortKey](a, b) * sign || b.date.localeCompare(a.date))
.map((item) => ({
...item,
day: dayFormat.format(asDate(item.date)),
categoryLabel: LABELS[item.category],
price: money(item.amount),
removeLabel: `Delete ${item.description} on ${dayFormat.format(asDate(item.date))}`,
}));
},
get columns() {
return COLUMNS.map((column) => ({
...column,
sort: column.key === this.sortKey ? SORT_NAMES[this.sortDirection] : "none",
icon:
column.key !== this.sortKey
? "arrow-up-down"
: this.sortDirection === "asc"
? "arrow-up"
: "arrow-down",
}));
},
get total() {
return money(sum(this.filtered));
},
get count() {
const count = this.filtered.length;
return `${count} ${count === 1 ? "expense" : "expenses"}`;
},
get average() {
return money(this.filtered.length ? sum(this.filtered) / this.filtered.length : 0);
},
get largest() {
const top = this.filtered.toSorted((a, b) => b.amount - a.amount)[0];
return top ? `${top.description}, ${money(top.amount)}` : "None";
},
get byCategory() {
return CATEGORIES.map((category) => ({
category: category.label,
amount:
Math.round(sum(this.filtered.filter((item) => item.category === category.id)) * 100) /
100,
})).filter((entry) => entry.amount > 0);
},
get months() {
const keys = [...new Set(this.items.map((item) => item.date.slice(0, 7)))].sort().reverse();
return keys.map((key, index) => {
const items = this.items.filter((item) => item.date.startsWith(key));
const total = sum(items);
const previous = keys[index + 1];
const before = previous
? sum(this.items.filter((item) => item.date.startsWith(previous)))
: 0;
const change = before ? Math.round(((total - before) / before) * 100) : null;
return {
key,
label: monthFormat.format(asDate(`${key}-01`)),
total: money(total),
count: `${items.length} ${items.length === 1 ? "expense" : "expenses"}`,
change:
change === null
? "First month"
: `${change > 0 ? "+" : ""}${change}% vs previous month`,
current: key === this.month,
};
});
},
get csv() {
return `data:text/csv;charset=utf-8,${encodeURIComponent(toCsv(this.rows))}`;
},
get filtering() {
return this.category !== "all" || this.month !== "all" || this.query.trim() !== "";
},
},
{ persist: "local" },
);
export const today = () => {
const now = new Date();
return new Date(now.getTime() - now.getTimezoneOffset() * 60_000).toISOString().slice(0, 10);
};
export const addExpense = (data) => {
expenses.items.push({
id: crypto.randomUUID(),
date: data.get("date"),
description: data.get("description").trim(),
category: data.get("category"),
amount: Math.round(Number(data.get("amount")) * 100) / 100,
});
};
export const removeExpense = (id) => {
expenses.items = expenses.items.filter((item) => item.id !== id);
};
export const sortBy = (key) => {
const same = expenses.sortKey === key;
expenses.sortDirection = same && expenses.sortDirection === "desc" ? "asc" : "desc";
expenses.sortKey = key;
};
export const toggleMonth = (key) => {
expenses.month = expenses.month === key ? "all" : key;
};
export const clearFilters = () => {
Object.assign(expenses, { category: "all", month: "all", query: "" });
};
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/button.html"></import-element>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/card.html"></import-element>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/empty.html"></import-element>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/input-group.html"></import-element>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/select.html"></import-element>
<custom-element name="expense-table">
<script type="module">
import { CATEGORIES, clearFilters, expenses, removeExpense, sortBy } from "./expenses.js";
const options = useState({ categories: CATEGORIES });
function sort(column) {
sortBy(column.key);
}
function filterCategory(select) {
expenses.category = select.value;
}
function filterMonth(select) {
expenses.month = select.value;
}
function search(field) {
expenses.query = field.value;
}
function remove(row) {
removeExpense(row.id);
}
</script>
<style>
:host {
display: block;
min-width: 0;
}
.filters {
display: grid;
grid-template-columns: minmax(0, 2fr) repeat(2, minmax(0, 1fr)) auto;
gap: calc(var(--spacing) * 2);
margin-bottom: calc(var(--spacing) * 4);
}
.clear:not([data-active]) {
visibility: hidden;
}
.table {
position: relative;
overflow-x: auto;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
}
table {
width: 100%;
min-width: calc(var(--spacing) * 140);
border-collapse: collapse;
font-size: var(--text-sm);
}
th {
height: calc(var(--spacing) * 10);
padding: 0 calc(var(--spacing) * 2);
color: var(--muted-foreground);
font-weight: var(--font-weight-medium);
text-align: left;
white-space: nowrap;
}
th button {
display: inline-flex;
align-items: center;
gap: calc(var(--spacing) * 1.5);
padding: calc(var(--spacing) * 1) calc(var(--spacing) * 1.5);
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
}
th button:hover {
background: var(--accent);
color: var(--accent-foreground);
}
th button:focus-visible {
outline: 2px solid var(--ring);
}
th[aria-sort="ascending"] button,
th[aria-sort="descending"] button {
color: var(--foreground);
}
td {
padding: calc(var(--spacing) * 2) calc(var(--spacing) * 3.5);
border-top: 1px solid var(--border);
white-space: nowrap;
}
tbody tr:hover {
background: var(--muted);
}
.amount {
text-align: right;
font-variant-numeric: tabular-nums;
}
th[data-column="amount"] {
text-align: right;
}
th[data-column="amount"] button {
flex-direction: row-reverse;
}
.actions {
width: calc(var(--spacing) * 12);
text-align: right;
}
tfoot td {
font-weight: var(--font-weight-semibold);
}
.category {
display: inline-flex;
align-items: center;
gap: calc(var(--spacing) * 2);
}
.category::before {
content: "";
width: calc(var(--spacing) * 2);
height: calc(var(--spacing) * 2);
border-radius: var(--radius-full);
background: var(--muted-foreground);
}
.category[data-category="food"]::before {
background: var(--chart-1);
}
.category[data-category="housing"]::before {
background: var(--chart-2);
}
.category[data-category="transport"]::before {
background: var(--chart-3);
}
.category[data-category="fun"]::before {
background: var(--chart-4);
}
.category[data-category="health"]::before {
background: var(--chart-5);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
@media (max-width: 720px) {
.filters {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.filters search {
grid-column: 1 / -1;
}
}
</style>
<ui-card>
<ui-card-header>
<ui-card-title level="2">Expenses</ui-card-title>
<ui-card-description><slot name="#expenses.count"></slot>, <slot name="#expenses.total"></slot> in total</ui-card-description>
</ui-card-header>
<ui-card-content>
<div class="filters">
<search>
<ui-input-group type="search" aria-label="Search expenses" placeholder="Search descriptions…" _value="#expenses.query" oninput="search(this)">
<ui-input-group-addon><u-icon name="search"></u-icon></ui-input-group-addon>
</ui-input-group>
</search>
<ui-select aria-label="Filter by category" _value="#expenses.category" onchange="filterCategory(this)">
<option value="all">All categories</option>
<option repeat="#options.categories" as="category" key="id" _value="#category.id" _label="#category.label"></option>
</ui-select>
<ui-select aria-label="Filter by month" _value="#expenses.month" onchange="filterMonth(this)">
<option value="all">All months</option>
<option repeat="#expenses.months" as="month" key="key" _value="#month.key" _label="#month.label"></option>
</ui-select>
<ui-button class="clear" variant="ghost" _data-active="#expenses.filtering" onclick="clearFilters()">Clear</ui-button>
</div>
<is-visible when="#expenses.rows.length">
<div class="table">
<table aria-label="Expenses">
<thead>
<tr>
<th repeat="#expenses.columns" as="column" key="key" scope="col" _data-column="#column.key" _aria-sort="#column.sort">
<button type="button" onclick="sort(column)"><slot name="#column.label"></slot><u-icon _name="#column.icon"></u-icon></button>
</th>
<th scope="col" class="actions"><span class="sr-only">Actions</span></th>
</tr>
</thead>
<tbody>
<tr repeat="#expenses.rows" as="row" key="id">
<td><slot name="#row.day"></slot></td>
<td><slot name="#row.description"></slot></td>
<td><span class="category" _data-category="#row.category"><slot name="#row.categoryLabel"></slot></span></td>
<td class="amount"><slot name="#row.price"></slot></td>
<td class="actions">
<ui-button variant="ghost" size="icon-sm" _aria-label="#row.removeLabel" onclick="remove(row)"><u-icon name="trash-2"></u-icon></ui-button>
</td>
</tr>
</tbody>
<tfoot>
<tr>
<td colspan="3">Total</td>
<td class="amount"><slot name="#expenses.total"></slot></td>
<td></td>
</tr>
</tfoot>
</table>
</div>
</is-visible>
<is-hidden when="#expenses.rows.length">
<ui-empty>
<ui-empty-header>
<ui-empty-media variant="icon"><u-icon name="receipt"></u-icon></ui-empty-media>
<ui-empty-title>No expenses found</ui-empty-title>
<ui-empty-description>Try other filters, or add an expense.</ui-empty-description>
</ui-empty-header>
</ui-empty>
</is-hidden>
</ui-card-content>
</ui-card>
</custom-element>