/** * Keeps a list of labeled amounts, sums them, and shows the formatted running * total — optionally against a budget, reporting what is left or how far over * it the total has gone. * * Every amount here is in whole currency units, not minor ones: `12.95` is * twelve dollars ninety-five, and passing `1295` for the same sum renders * `$1295.00`. `budget` is measured the same way. * * Embed it as `` — a budget * of twelve hundred dollars — and read the total off the host's own cell. * * Internally the sum is taken in cents and formatted once at the edge, which is * what keeps the displayed total equal to the sum of the displayed rows rather * than a float that drifts a penny away from them. That is a rounding device, * not the unit of the input. * * @hashtags expenses, budget, total, money, ledger, spending * @keywords running total, sum, expense tracker, budget tracker, spending, * cost, price, amount, currency, dollars, USD, subtotal, add expense, * remaining budget, over budget */ import { action, computed, Default, ifElse, NAME, pattern, Stream, UI, type VNode, Writable, } from "commonfabric"; export interface AmountEntry { label: string; /** In whole currency units. `12.95` is twelve dollars ninety-five. */ amount: number; } export interface AmountLedgerInput { /** The entries being totalled. A host passes its own cell to share them. */ entries?: Writable>; /** Prefixed to every formatted amount. */ currencySymbol?: Writable>; /** * Total to measure spending against, in whole currency units. Zero means no * budget is shown. */ budget?: Writable>; /** Heading shown above the entries. */ label?: Writable>; } export interface AmountLedgerOutput { [NAME]: string; [UI]: VNode; entries: AmountEntry[]; total: number; formattedTotal: string; remaining: number; overBudget: boolean; entryCount: number; addEntry: Stream<{ label: string; amount: number }>; removeEntry: Stream<{ entry: AmountEntry }>; } /** Cents for an amount given in whole currency units. */ const cents = (amount: number): number => Math.round((amount || 0) * 100); /** `amount` rendered under `symbol`, to the penny. */ const money = (symbol: string, amount: number): string => `${symbol}${(amount || 0).toFixed(2)}`; export const AmountLedger = pattern( ({ entries, currencySymbol, budget, label }) => { const draftLabel = new Writable(""); const draftAmount = new Writable(""); const addEntry = action( ({ label: entryLabel, amount }: { label: string; amount: number }) => { const trimmed = entryLabel.trim(); if (!trimmed) return; entries.push({ label: trimmed, amount: cents(amount) / 100 }); }, ); const removeEntry = action(({ entry }: { entry: AmountEntry }) => { // Plain reads of inline rows carry value references; mapped UI rows // carry the live slot reference. const index = entries.get().findIndex((candidate, index) => Writable.equals(entries.key(index), entry) || Writable.equals(candidate, entry) ); if (index < 0) return; entries.set(entries.get().filter((_, position) => position !== index)); }); const total = computed(() => entries.get().reduce((sum, entry) => sum + cents(entry.amount), 0) / 100 ); const formattedTotal = computed(() => money(currencySymbol.get(), total)); const remaining = computed(() => { const limit = budget.get() ?? 0; return limit === 0 ? 0 : (cents(limit) - cents(total)) / 100; }); const hasBudget = computed(() => (budget.get() ?? 0) !== 0); const overBudget = computed(() => hasBudget && remaining < 0); const entryCount = computed(() => entries.get().length); const isEmpty = computed(() => entryCount === 0); const rows = entries.map((entry: AmountEntry) => ( {entry.label} {computed(() => money(currencySymbol.get(), cents(entry.amount) / 100) )} removeEntry.send({ entry })} > Remove )); return { [NAME]: computed(() => `${label.get()}: ${formattedTotal}`), [UI]: ( {label} {rows} {ifElse(isEmpty, , null)} Total {formattedTotal} {ifElse( hasBudget, {computed(() => overBudget ? "Over budget by" : "Left")} {computed(() => money(currencySymbol.get(), Math.abs(remaining)) )} , null, )} { addEntry.send({ label: draftLabel.get(), amount: Number(draftAmount.get()) || 0, }); draftLabel.set(""); draftAmount.set(""); }} > Add ), entries, total, formattedTotal, remaining, overBudget, entryCount, addEntry, removeEntry, }; }, ); export default AmountLedger;