/**
* 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;