feat(money): one decimals setting for the dataset, not one per currency

`Settings.digits` replaces `CurrencyDef.digits`. Every amount rounds to it,
whatever its currency, and `digitsFor` is gone: the sixteen call sites read the
setting, and the domain functions that took a currency table purely to look
digits up take a `digits: number` instead. Schema 6.

The owner asked for this with the cost in front of them, and the cost is real:
a dataset mixing minor units cannot be described any more. Yen at 0 beside
kroner at 2 has no correct global value — 2 gives ¥1234.56, an amount yen
cannot represent; 0 rounds 49.95 DKK to 50 and drifts the carry from the bank.
Nothing in the app warns. docs/specs/2026-09-02-global-decimals-design.md
argues it in full, including what re-adding a per-currency override would take,
and AGENTS.md §1 now says not to reintroduce one without revisiting the
decision.

The 5 -> 6 step takes the MAXIMUM of the per-currency digits, not the base
currency's. A dataset holding a three-decimal currency already has
three-decimal amounts in it, and adopting the base's 2 would truncate the next
edit of one; the maximum can only keep more precision, never less. On every
dataset this app has written all four currencies record 2, so nothing rounds
differently than before.

Changing the setting does not rewrite stored amounts. 49.95 stays 49.95 and
shows as 50; totals recompute at the new precision. Rewriting each Money is a
destructive whole-dataset write, which §1 owes a backup export first, and it is
lossy one way.

Display turned out to be the hole in the plan. src/ui/format.ts pinned
minimumFractionDigits: 2 in a module-level Intl.NumberFormat, so it never read
the dataset at all — equally broken under per-currency digits, where a JPY
rendered "3,333.00", so a pre-existing gap rather than a regression. Shipping a
Settings control that display ignores would look broken at 0 and be lossy at 3,
hiding precision the dataset holds. So format.ts takes digits like roundMoney
does, useMoneyFormat() binds them for the 64 call sites, and postRowCells takes
the formatter as an argument because it is a plain helper, not a component.

currencyDigits.test.ts is rewritten rather than deleted. Driving a dataset at
ZERO decimals through rounding, both FX directions, both split modes, plan
division, the fold and the month view still tells a real implementation from
one assuming 2 — it just cannot prove per-currency flow, because there is none.
It also asserts that raising the setting to 2 changes what the fold produces.

Verified in Chrome, not only in tests: a v5 dataset with digits 2/0/3 written
straight into IndexedDB migrates to a setting of 3, the table loses its
Decimals column, and editing the control persists schema 6 with digits stripped
from each currency and income left at 20000.55. The same month reads 600/206 at
zero decimals and 600.000/205.561 at three, so the figures recompute rather
than merely reformat.

Not verified here: the .ods in a real spreadsheet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucas Winther
2026-09-03 03:12:14 +02:00
co-authored by Claude Opus 5
parent ebef56eef4
commit 8d2b2ca881
47 changed files with 1039 additions and 426 deletions
+4 -4
View File
@@ -1,11 +1,10 @@
import { useRef } from "react";
import { XIcon } from "lucide-react";
import { useDataset } from "../hooks/useDataset.ts";
import { digitsFor } from "../../domain/currencies.ts";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { formatMoney } from "../format.ts";
import { useMoneyFormat } from "../hooks/useMoneyFormat.ts";
import {
bulkTotal,
isBlankLine,
@@ -28,7 +27,8 @@ interface Props {
* on touch devices too. That is why there is no "Add line" button.
*/
export function BulkLines({ draft, onChange }: Props) {
const digits = digitsFor(useDataset().currencies, draft.currency);
const digits = useDataset().settings.digits;
const fmt = useMoneyFormat();
const descriptionRefs = useRef<Array<HTMLInputElement | null>>([]);
const noteRefs = useRef<Array<HTMLInputElement | null>>([]);
@@ -135,7 +135,7 @@ export function BulkLines({ draft, onChange }: Props) {
<p className="text-right text-xs text-muted-foreground">
{filledCount} line{filledCount === 1 ? "" : "s"} &middot;{" "}
<span className="font-money">{formatMoney(bulkTotal(draft, digits), draft.currency)}</span>
<span className="font-money">{fmt.money(bulkTotal(draft, digits), draft.currency)}</span>
</p>
</fieldset>
);
+5 -5
View File
@@ -1,9 +1,8 @@
import { Button } from "@/components/ui/button";
import { useDataset } from "../hooks/useDataset.ts";
import { digitsFor } from "../../domain/currencies.ts";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { formatAmount } from "../format.ts";
import { useMoneyFormat } from "../hooks/useMoneyFormat.ts";
import {
planBalance,
setSliceAmount,
@@ -32,7 +31,8 @@ function hasValidDate(draft: PurchaseDraft): boolean {
export function PlanEditor({ draft, onChange }: Props) {
// The purchase's own currency decides the rounding, not the base currency:
// a plan for a JPY purchase must divide into whole yen.
const digits = digitsFor(useDataset().currencies, draft.currency);
const digits = useDataset().settings.digits;
const fmt = useMoneyFormat();
const balance = planBalance(draft, digits);
if (!draft.plan) {
@@ -105,8 +105,8 @@ export function PlanEditor({ draft, onChange }: Props) {
<p className={`text-xs ${balance === 0 ? "text-muted-foreground" : ""}`}>
{balance === 0
? `Slices total ${formatAmount(draft.amount)} ${draft.currency}, matching the purchase.`
: `Slices are ${formatAmount(Math.abs(balance))} ${draft.currency} ${
? `Slices total ${fmt.amount(draft.amount)} ${draft.currency}, matching the purchase.`
: `Slices are ${fmt.amount(Math.abs(balance))} ${draft.currency} ${
balance > 0 ? "short of" : "over"
} the purchase total.`}
</p>
+14 -8
View File
@@ -1,6 +1,6 @@
import { Link } from "react-router";
import { Badge } from "@/components/ui/badge";
import { formatAmount, formatSignedAmount } from "../format.ts";
import { useMoneyFormat, type MoneyFormat } from "../hooks/useMoneyFormat.ts";
import { ruleAt } from "../../domain/allocation.ts";
import { carryMeterSegments, type MeterSegment } from "../meterSegments.ts";
import { Meter } from "./Meter.tsx";
@@ -41,18 +41,23 @@ interface RowCells {
segments: MeterSegment[];
}
function postRowCells({ figures }: MonthPostRow): RowCells {
/**
* Takes the formatter as an argument rather than calling the hook: this is a
* plain function, not a component, and both table markups call it from inside
* a map.
*/
function postRowCells({ figures }: MonthPostRow, fmt: MoneyFormat): RowCells {
return {
carriedIn: formatSignedAmount(figures.carriedIn),
carriedIn: fmt.signedAmount(figures.carriedIn),
carriedInTone:
figures.carriedIn > 0
? "text-surplus"
: figures.carriedIn < 0
? "text-overspend"
: "",
allocation: formatAmount(figures.allocation),
charges: formatAmount(figures.charges),
remaining: formatSignedAmount(figures.remaining),
allocation: fmt.amount(figures.allocation),
charges: fmt.amount(figures.charges),
remaining: fmt.signedAmount(figures.remaining),
remainingTone: figures.remaining < 0 ? "text-overspend" : "",
segments: carryMeterSegments(figures),
};
@@ -161,6 +166,7 @@ function ChangeRuleButton({
}
export function PostTable({ monthId, rows, onChangeRule }: Props) {
const fmt = useMoneyFormat();
return (
<>
<div className="hidden overflow-x-auto sm:block">
@@ -180,7 +186,7 @@ export function PostTable({ monthId, rows, onChangeRule }: Props) {
belong together, which is exactly how the meter came to look like
a divider between two rows rather than part of one. */}
{rows.map((row) => {
const cells = postRowCells(row);
const cells = postRowCells(row, fmt);
return (
<tbody key={row.post.id} className="group">
<tr className="transition-colors group-hover:bg-accent/60">
@@ -224,7 +230,7 @@ export function PostTable({ monthId, rows, onChangeRule }: Props) {
<ul className="space-y-4 sm:hidden">
{rows.map((row) => {
const cells = postRowCells(row);
const cells = postRowCells(row, fmt);
return (
<li key={row.post.id} className="space-y-1.5">
<div className="flex items-baseline justify-between gap-3 text-sm">
+4 -4
View File
@@ -1,11 +1,10 @@
import { Button } from "@/components/ui/button";
import { useDataset } from "../hooks/useDataset.ts";
import { digitsFor } from "../../domain/currencies.ts";
import { Input } from "@/components/ui/input";
import { NativeSelect } from "@/components/ui/native-select";
import { Label } from "@/components/ui/label";
import { Segmented } from "./Segmented.tsx";
import { formatAmount } from "../format.ts";
import { useMoneyFormat } from "../hooks/useMoneyFormat.ts";
import type { PurchaseDraft, SplitDraft } from "../purchaseForm.ts";
import { splitBalance } from "../purchaseForm.ts";
import type { Post } from "../../domain/types.ts";
@@ -17,7 +16,8 @@ interface Props {
}
export function SplitEditor({ draft, posts, onChange }: Props) {
const balance = splitBalance(draft, digitsFor(useDataset().currencies, draft.currency));
const balance = splitBalance(draft, useDataset().settings.digits);
const fmt = useMoneyFormat();
const unit = draft.splitMode === "percent" ? "%" : draft.currency;
function updateSplit(index: number, changes: Partial<SplitDraft>) {
@@ -133,7 +133,7 @@ export function SplitEditor({ draft, posts, onChange }: Props) {
<span className={balance === 0 ? "text-muted-foreground" : ""}>
{balance === 0
? "Splits balance exactly."
: `${formatAmount(balance)} ${unit} unassigned — the rounding post absorbs it.`}
: `${fmt.amount(balance)} ${unit} unassigned — the rounding post absorbs it.`}
</span>
</div>
</fieldset>
+6 -5
View File
@@ -1,5 +1,5 @@
import { Link } from "react-router";
import { formatAmount } from "../format.ts";
import { useMoneyFormat } from "../hooks/useMoneyFormat.ts";
import type { YearViewModel } from "../../domain/views.ts";
interface Props {
@@ -14,6 +14,7 @@ interface Props {
* recomputes rollover.
*/
export function YearMatrix({ view, mode }: Props) {
const fmt = useMoneyFormat();
return (
// Thirteen columns of figures cannot fit any realistic card, so the table
// scrolls. The fade tells you that: without it the matrix just appears to
@@ -37,11 +38,11 @@ export function YearMatrix({ view, mode }: Props) {
<td className="py-2">Income</td>
{view.incomeByMonth.map((income, i) => (
<td key={i} className="font-money py-2 pl-4 text-right whitespace-nowrap">
{formatAmount(income)}
{fmt.amount(income)}
</td>
))}
<td className="font-money py-2 pl-4 text-right whitespace-nowrap">
{formatAmount(view.totalIncome)}
{fmt.amount(view.totalIncome)}
</td>
</tr>
@@ -61,7 +62,7 @@ export function YearMatrix({ view, mode }: Props) {
mode === "balance" && value < 0 ? "text-overspend" : ""
}`}
>
{formatAmount(value)}
{fmt.amount(value)}
</td>
);
})}
@@ -70,7 +71,7 @@ export function YearMatrix({ view, mode }: Props) {
mode === "balance" && row.closingBalance < 0 ? "text-overspend" : ""
}`}
>
{formatAmount(mode === "spend" ? row.totalCharges : row.closingBalance)}
{fmt.amount(mode === "spend" ? row.totalCharges : row.closingBalance)}
</td>
</tr>
))}
+38 -11
View File
@@ -6,24 +6,51 @@ import {
formatSignedMoney,
} from "./format.ts";
test("formatAmount groups thousands and always shows two decimals", () => {
expect(formatAmount(1234.5)).toBe("1,234.50");
expect(formatAmount(0)).toBe("0.00");
expect(formatAmount(-42)).toBe("-42.00");
test("formatAmount groups thousands and shows the digits it is given", () => {
expect(formatAmount(1234.5, 2)).toBe("1,234.50");
expect(formatAmount(0, 2)).toBe("0.00");
expect(formatAmount(-42, 2)).toBe("-42.00");
});
test("formatMoney appends the currency code", () => {
expect(formatMoney(1234.5, "DKK")).toBe("1,234.50 DKK");
expect(formatMoney(1234.5, "DKK", 2)).toBe("1,234.50 DKK");
});
test("formatSignedMoney marks positive balances with a plus", () => {
expect(formatSignedMoney(200, "DKK")).toBe("+200.00 DKK");
expect(formatSignedMoney(-200, "DKK")).toBe("-200.00 DKK");
expect(formatSignedMoney(0, "DKK")).toBe("0.00 DKK");
expect(formatSignedMoney(200, "DKK", 2)).toBe("+200.00 DKK");
expect(formatSignedMoney(-200, "DKK", 2)).toBe("-200.00 DKK");
expect(formatSignedMoney(0, "DKK", 2)).toBe("0.00 DKK");
});
test("formatSignedAmount signs the number without naming a currency", () => {
expect(formatSignedAmount(200)).toBe("+200.00");
expect(formatSignedAmount(-200)).toBe("-200.00");
expect(formatSignedAmount(0)).toBe("0.00");
expect(formatSignedAmount(200, 2)).toBe("+200.00");
expect(formatSignedAmount(-200, 2)).toBe("-200.00");
expect(formatSignedAmount(0, 2)).toBe("0.00");
});
/**
* Display is a rounding boundary like any other, and the dataset's decimals
* setting is what it rounds to. A pinned 2 made the setting invisible at zero
* — whole yen still rendered "3,333.00" — and LOSSY at three or four, where it
* hid precision the dataset really holds.
*/
test("the digit count is the dataset's setting, not a fixed two", () => {
expect(formatAmount(3333, 0)).toBe("3,333");
expect(formatAmount(1.234, 3)).toBe("1.234");
expect(formatAmount(1.2345, 4)).toBe("1.2345");
expect(formatMoney(3333, "JPY", 0)).toBe("3,333 JPY");
expect(formatSignedMoney(3333, "JPY", 0)).toBe("+3,333 JPY");
expect(formatSignedAmount(-3333, 0)).toBe("-3,333");
});
test("both bounds are pinned, so a whole number still shows its decimals", () => {
// minimumFractionDigits as well as maximum: an even 200 must read "200.00"
// beside "199.95", or a column of money stops lining up.
expect(formatAmount(200, 2)).toBe("200.00");
expect(formatAmount(200, 3)).toBe("200.000");
});
/** The locale stays pinned, so output cannot differ between a machine and CI. */
test("grouping and the decimal mark do not follow the host locale", () => {
expect(formatAmount(1234567.89, 2)).toBe("1,234,567.89");
});
+39 -13
View File
@@ -1,17 +1,39 @@
import type { Currency } from "../domain/types.ts";
// Pinned locale: output must not vary between the user's machine and CI.
const NUMBER = new Intl.NumberFormat("en-GB", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
/**
* Display is a rounding boundary, and `settings.digits` is what it rounds to —
* so digits are passed in here exactly as they are to `roundMoney`, with no
* default. A pinned 2 used to live in this file, which made the setting
* invisible at zero (whole yen still read "3,333.00") and lossy at three or
* four, where it hid precision the dataset genuinely holds.
*
* The locale stays pinned: output must not vary between the owner's machine
* and CI. Formatters are cached per digit count, because constructing an
* `Intl.NumberFormat` is the expensive part and a table with thirty money
* cells would otherwise build thirty of them per render.
*/
const FORMATTERS = new Map<number, Intl.NumberFormat>();
export function formatAmount(amount: number): string {
return NUMBER.format(amount);
function formatter(digits: number): Intl.NumberFormat {
let found = FORMATTERS.get(digits);
if (!found) {
found = new Intl.NumberFormat("en-GB", {
// Both bounds, so an even 200 reads "200.00" beside "199.95" and a
// column of money still lines up.
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
FORMATTERS.set(digits, found);
}
return found;
}
export function formatMoney(amount: number, currency: Currency): string {
return `${formatAmount(amount)} ${currency}`;
export function formatAmount(amount: number, digits: number): string {
return formatter(digits).format(amount);
}
export function formatMoney(amount: number, currency: Currency, digits: number): string {
return `${formatAmount(amount, digits)} ${currency}`;
}
/**
@@ -20,10 +42,14 @@ export function formatMoney(amount: number, currency: Currency): string {
* the dataset's base currency, and repeating the code on all thirty cells only
* bought a line wrap at phone width.
*/
export function formatSignedAmount(amount: number): string {
return `${amount > 0 ? "+" : ""}${formatAmount(amount)}`;
export function formatSignedAmount(amount: number, digits: number): string {
return `${amount > 0 ? "+" : ""}${formatAmount(amount, digits)}`;
}
export function formatSignedMoney(amount: number, currency: Currency): string {
return `${formatSignedAmount(amount)} ${currency}`;
export function formatSignedMoney(
amount: number,
currency: Currency,
digits: number,
): string {
return `${formatSignedAmount(amount, digits)} ${currency}`;
}
+37
View File
@@ -0,0 +1,37 @@
import { useMemo } from "react";
import { useDataset } from "./useDataset.ts";
import {
formatAmount,
formatMoney,
formatSignedAmount,
formatSignedMoney,
} from "../format.ts";
import type { Currency } from "../../domain/types.ts";
/**
* The four money formatters, bound to the dataset's decimals setting.
*
* A hook rather than digits threaded through props: display is needed at every
* depth — a table cell, a hint under an input, a stat tile — and `settings.digits`
* is one value for the whole dataset, so a component deep in a tree asking for
* it directly is honest rather than a shortcut. The underlying functions in
* `../format.ts` still take digits explicitly, which is what keeps them
* testable without a React tree.
*/
export function useMoneyFormat() {
const digits = useDataset().settings.digits;
return useMemo(
() => ({
digits,
amount: (amount: number) => formatAmount(amount, digits),
money: (amount: number, currency: Currency) => formatMoney(amount, currency, digits),
signedAmount: (amount: number) => formatSignedAmount(amount, digits),
signedMoney: (amount: number, currency: Currency) =>
formatSignedMoney(amount, currency, digits),
}),
[digits],
);
}
/** What `useMoneyFormat` returns, for a component that takes it as a prop. */
export type MoneyFormat = ReturnType<typeof useMoneyFormat>;
+7 -5
View File
@@ -12,7 +12,7 @@ import { setIncome, deletePurchase, setRuleFrom } from "../../store/actions.ts";
import { ruleAt } from "../../domain/allocation.ts";
import type { MonthId, Post, Purchase, Rule } from "../../domain/types.ts";
import { sliceAmountForMonth } from "../../domain/charges.ts";
import { formatMoney, formatSignedMoney } from "../format.ts";
import { useMoneyFormat } from "../hooks/useMoneyFormat.ts";
import { PostTable, PostTableLegend } from "../components/PostTable.tsx";
import { PurchaseDialog } from "../components/PurchaseDialog.tsx";
import { Meter } from "../components/Meter.tsx";
@@ -21,6 +21,7 @@ import { allocatedPercentOfIncome, allocationMeterSegments } from "../meterSegme
import { groupPurchasesByDate } from "../purchaseGroups.ts";
export function MonthRoute() {
const fmt = useMoneyFormat();
const { monthId = "" } = useParams();
const dataset = useDataset();
const { mutate, error, clearError } = useMutate();
@@ -85,7 +86,7 @@ export function MonthRoute() {
</div>
<div className="min-w-0 flex-1 space-y-2">
<dl className="flex flex-wrap gap-x-10 gap-y-4">
<Stat label="Allocated">{formatMoney(view.totalAllocation, base)}</Stat>
<Stat label="Allocated">{fmt.money(view.totalAllocation, base)}</Stat>
<Stat
label="Unallocated"
tone={view.unallocated < 0 ? "overspend" : "default"}
@@ -95,7 +96,7 @@ export function MonthRoute() {
: undefined
}
>
{formatSignedMoney(view.unallocated, base)}
{fmt.signedMoney(view.unallocated, base)}
</Stat>
</dl>
<Meter
@@ -108,7 +109,7 @@ export function MonthRoute() {
<div className="mt-4 flex flex-wrap items-end justify-between gap-x-10 gap-y-2 border-t border-budget-rule pt-3">
<dl>
<Stat label="Spent">{formatMoney(view.totalCharges, base)}</Stat>
<Stat label="Spent">{fmt.money(view.totalCharges, base)}</Stat>
</dl>
{/* Rendered in both directions. "No posts overspent" is a real answer
to the app's second question, and a line that only appears when
@@ -189,6 +190,7 @@ function PurchaseRow({
monthId: MonthId;
onDelete: () => void;
}) {
const fmt = useMoneyFormat();
const slice = sliceAmountForMonth(purchase, monthId)!;
return (
// `-mx-2 px-2` rather than `px-1`: the row's text now starts exactly where
@@ -221,7 +223,7 @@ function PurchaseRow({
</span>
)}
<span className="font-money shrink-0 tabular-nums">
{formatMoney(slice.amount, slice.currency)}
{fmt.money(slice.amount, slice.currency)}
</span>
{/* From `sm:` up: revealed on hover, and held at a fixed width so
+9 -8
View File
@@ -3,9 +3,10 @@ import { useDataset } from "../hooks/useDataset.ts";
import { monthView } from "../../domain/views.ts";
import { chargesForPurchaseInMonth, sliceAmountForMonth } from "../../domain/charges.ts";
import { addMonths, compareMonths } from "../../domain/months.ts";
import { formatMoney, formatSignedMoney } from "../format.ts";
import { useMoneyFormat } from "../hooks/useMoneyFormat.ts";
export function PostMonthRoute() {
const fmt = useMoneyFormat();
const { postId = "", monthId = "" } = useParams();
const dataset = useDataset();
const post = dataset.posts.find((p) => p.id === postId);
@@ -20,7 +21,7 @@ export function PostMonthRoute() {
monthId,
base,
dataset.fxRates,
dataset.currencies,
dataset.settings.digits,
);
const mine = charges.find((c) => c.postId === postId);
return mine ? [{ purchase, amount: mine.amount }] : [];
@@ -72,23 +73,23 @@ export function PostMonthRoute() {
: ""
}`}
>
{formatSignedMoney(row?.figures.carriedIn ?? 0, base)}
{fmt.signedMoney(row?.figures.carriedIn ?? 0, base)}
</dd>
</div>
<div>
<dt className="text-muted-foreground">Allocated</dt>
<dd className="font-money">{formatMoney(row?.figures.allocation ?? 0, base)}</dd>
<dd className="font-money">{fmt.money(row?.figures.allocation ?? 0, base)}</dd>
</div>
<div>
<dt className="text-muted-foreground">Spent</dt>
<dd className="font-money">{formatMoney(row?.figures.charges ?? 0, base)}</dd>
<dd className="font-money">{fmt.money(row?.figures.charges ?? 0, base)}</dd>
</div>
<div>
<dt className="text-muted-foreground">Remaining</dt>
<dd
className={`font-money ${(row?.figures.remaining ?? 0) < 0 ? "text-overspend" : ""}`}
>
{formatSignedMoney(row?.figures.remaining ?? 0, base)}
{fmt.signedMoney(row?.figures.remaining ?? 0, base)}
</dd>
</div>
</dl>
@@ -107,7 +108,7 @@ export function PostMonthRoute() {
{purchase.splits.length > 1 && (
<span className="text-xs text-muted-foreground">split</span>
)}
<span className="font-money">{formatMoney(amount, base)}</span>
<span className="font-money">{fmt.money(amount, base)}</span>
</li>
))}
{thisMonth.length === 0 && <li className="py-2 text-muted-foreground">Nothing yet.</li>}
@@ -123,7 +124,7 @@ export function PostMonthRoute() {
<span className="w-20">{slice.month}</span>
<span className="flex-1">{purchase.description}</span>
<span className="font-money">
{formatMoney(slice.amount.amount, slice.amount.currency)}
{fmt.money(slice.amount.amount, slice.amount.currency)}
</span>
</li>
))}
+9 -8
View File
@@ -1,9 +1,10 @@
import { Link, useParams } from "react-router";
import { useDataset } from "../hooks/useDataset.ts";
import { yearView } from "../../domain/views.ts";
import { formatAmount, formatMoney, formatSignedMoney } from "../format.ts";
import { useMoneyFormat } from "../hooks/useMoneyFormat.ts";
export function PostYearRoute() {
const fmt = useMoneyFormat();
const { postId = "", year = "" } = useParams();
const dataset = useDataset();
const post = dataset.posts.find((p) => p.id === postId);
@@ -20,9 +21,9 @@ export function PostYearRoute() {
{post.name} &middot; {year}
</h1>
<p className="text-sm text-muted-foreground">
Allocated {formatMoney(row.totalAllocation, base)} &middot; spent{" "}
{formatMoney(row.totalCharges, base)} &middot; closing balance{" "}
{formatSignedMoney(row.closingBalance, base)}
Allocated {fmt.money(row.totalAllocation, base)} &middot; spent{" "}
{fmt.money(row.totalCharges, base)} &middot; closing balance{" "}
{fmt.signedMoney(row.closingBalance, base)}
</p>
<div className="overflow-x-auto">
@@ -55,16 +56,16 @@ export function PostYearRoute() {
: ""
}`}
>
{formatSignedMoney(figures.carriedIn, base)}
{fmt.signedMoney(figures.carriedIn, base)}
</td>
<td className="font-money py-2 text-right">{formatAmount(figures.allocation)}</td>
<td className="font-money py-2 text-right">{formatAmount(figures.charges)}</td>
<td className="font-money py-2 text-right">{fmt.amount(figures.allocation)}</td>
<td className="font-money py-2 text-right">{fmt.amount(figures.charges)}</td>
<td
className={`font-money py-2 text-right font-medium ${
figures.remaining < 0 ? "text-overspend" : ""
}`}
>
{formatSignedMoney(figures.remaining, base)}
{fmt.signedMoney(figures.remaining, base)}
</td>
</tr>
);
+7 -6
View File
@@ -5,11 +5,12 @@ import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { useDataset } from "../hooks/useDataset.ts";
import { datasetMonthSpan, summaryView } from "../../domain/views.ts";
import { formatAmount, formatMoney } from "../format.ts";
import { useMoneyFormat } from "../hooks/useMoneyFormat.ts";
import { Section, Stat } from "../components/Section.tsx";
import { Segmented } from "../components/Segmented.tsx";
export function SummaryRoute() {
const fmt = useMoneyFormat();
const dataset = useDataset();
const span = datasetMonthSpan(dataset);
const [from, setFrom] = useState(span.from);
@@ -46,10 +47,10 @@ export function SummaryRoute() {
</div>
<dl className="mt-5 flex flex-wrap gap-x-10 gap-y-4 border-t border-budget-rule pt-5">
<Stat label="Income">{formatMoney(view.totalIncome, base)}</Stat>
<Stat label="Spent">{formatMoney(view.totalCharges, base)}</Stat>
<Stat label="Income">{fmt.money(view.totalIncome, base)}</Stat>
<Stat label="Spent">{fmt.money(view.totalCharges, base)}</Stat>
<Stat label="Difference" tone={difference < 0 ? "overspend" : "default"}>
{formatMoney(difference, base)}
{fmt.money(difference, base)}
</Stat>
</dl>
</Section>
@@ -81,7 +82,7 @@ export function SummaryRoute() {
? view.byPost.map((entry) => (
<tr key={entry.post.id} className="border-b border-budget-rule transition-colors last:border-0 hover:bg-accent/60">
<td className="py-2.5">{entry.post.name}</td>
<td className="font-money py-2.5 pl-6 text-right">{formatAmount(entry.charges)}</td>
<td className="font-money py-2.5 pl-6 text-right">{fmt.amount(entry.charges)}</td>
<td className="font-money py-2.5 pl-6 text-right text-budget-ink-muted">
{view.totalCharges === 0
? "—"
@@ -96,7 +97,7 @@ export function SummaryRoute() {
{entry.monthId}
</Link>
</td>
<td className="font-money py-2.5 pl-6 text-right">{formatAmount(entry.charges)}</td>
<td className="font-money py-2.5 pl-6 text-right">{fmt.amount(entry.charges)}</td>
<td className="font-money py-2.5 pl-6 text-right text-budget-ink-muted">
{view.totalCharges === 0
? "—"
+4 -3
View File
@@ -2,12 +2,13 @@ import { useState } from "react";
import { Link, useParams } from "react-router";
import { useDataset } from "../hooks/useDataset.ts";
import { yearView } from "../../domain/views.ts";
import { formatMoney } from "../format.ts";
import { useMoneyFormat } from "../hooks/useMoneyFormat.ts";
import { YearMatrix } from "../components/YearMatrix.tsx";
import { Segmented } from "../components/Segmented.tsx";
import { Section, Stat } from "../components/Section.tsx";
export function YearRoute() {
const fmt = useMoneyFormat();
const { year = "" } = useParams();
const dataset = useDataset();
const [mode, setMode] = useState<"spend" | "balance">("spend");
@@ -35,8 +36,8 @@ export function YearRoute() {
<Section>
<dl className="flex flex-wrap gap-x-10 gap-y-4">
<Stat label="Income">{formatMoney(view.totalIncome, base)}</Stat>
<Stat label="Spent">{formatMoney(view.totalCharges, base)}</Stat>
<Stat label="Income">{fmt.money(view.totalIncome, base)}</Stat>
<Stat label="Spent">{fmt.money(view.totalCharges, base)}</Stat>
</dl>
</Section>
+64 -68
View File
@@ -10,6 +10,7 @@ import {
addCurrency,
removeCurrency,
setBaseCurrency,
setDigits,
setFxRate,
removeFxRate,
updateCurrency,
@@ -68,30 +69,66 @@ export function CurrencySection() {
>
{error && <p className="mb-3 text-sm text-destructive">{error}</p>}
<div className="mb-5 space-y-1.5">
<Label
htmlFor="base-currency"
className="text-[0.6875rem] font-medium uppercase tracking-wider text-budget-ink-muted"
>
Base currency (all totals use this)
</Label>
<NativeSelect
id="base-currency"
className="w-40"
value={base}
onChange={(event) => {
const currency = event.target.value;
mutate((draft) => setBaseCurrency(draft, currency));
}}
>
{dataset.currencies.map(({ code }) => (
<option key={code} value={code}>
{code}
</option>
))}
</NativeSelect>
<div className="mb-5 flex flex-wrap items-end gap-x-8 gap-y-4">
<div className="space-y-1.5">
<Label
htmlFor="base-currency"
className="text-[0.6875rem] font-medium uppercase tracking-wider text-budget-ink-muted"
>
Base currency (all totals use this)
</Label>
<NativeSelect
id="base-currency"
className="w-40"
value={base}
onChange={(event) => {
const currency = event.target.value;
mutate((draft) => setBaseCurrency(draft, currency));
}}
>
{dataset.currencies.map(({ code }) => (
<option key={code} value={code}>
{code}
</option>
))}
</NativeSelect>
</div>
<div className="space-y-1.5">
<Label
htmlFor="digits"
className="text-[0.6875rem] font-medium uppercase tracking-wider text-budget-ink-muted"
>
Decimals (all amounts)
</Label>
<Input
id="digits"
type="number"
min="0"
max="4"
step="1"
className="font-money h-9 w-20"
value={dataset.settings.digits}
onChange={(event) => {
// Captured before mutate, which defers behind the write queue —
// React resets the DOM value first, so reading it inside the
// callback commits the OLD one. AGENTS.md §2.
const digits = Number(event.target.value);
// Guarded here rather than letting setDigits throw: this is an
// event handler, and no error boundary catches a throw from one.
if (!Number.isInteger(digits) || digits < 0 || digits > 4) return;
mutate((draft) => setDigits(draft, digits));
}}
/>
</div>
</div>
<p className="mb-5 max-w-[70ch] text-xs leading-relaxed text-budget-ink-muted">
Every currency rounds to this. Changing it does not rewrite amounts you
have already recorded — they keep the precision they were entered with,
and totals recompute at the new setting.
</p>
{/* `-mr-2 pr-2`, not a bare `overflow-x-auto`: the row's remove button
carries `-mr-2` so its label sits flush with the card's content edge
rather than 8px inside it. That overhang is 8px of overflow to a
@@ -107,7 +144,6 @@ export function CurrencySection() {
<th className="py-2 font-medium">Code</th>
<th className="py-2 pl-4 font-medium">Name</th>
<th className="py-2 pl-4 font-medium">Symbol</th>
<th className="py-2 pl-4 text-right font-medium">Decimals</th>
<th className="py-2 pl-4 text-right font-medium">1 unit = ? {base}</th>
<th className="py-2 pl-4 text-right font-medium">Source</th>
<th className="py-2 pl-4" />
@@ -146,22 +182,6 @@ export function CurrencySection() {
}}
/>
</td>
<td className="py-2 pl-4 text-right">
<Input
type="number"
min="0"
max="4"
step="1"
className="font-money ml-auto h-8 w-16 text-right"
aria-label={`Decimal places for ${currency.code}`}
value={currency.digits}
onChange={(event) => {
const digits = Number(event.target.value);
if (!Number.isInteger(digits) || digits < 0 || digits > 4) return;
mutate((draft) => updateCurrency(draft, currency.code, { digits }));
}}
/>
</td>
<td className="py-2 pl-4 text-right">
{isBase ? (
<span className="font-money text-budget-ink-muted">1</span>
@@ -265,28 +285,21 @@ export function CurrencySection() {
}
/**
* Decimals default to 2 because almost every currency uses hundredths; the
* field is there for the ones that do not (yen at 0, dinars at 3).
* No decimals field: they are one setting for the whole dataset now, edited
* above the table rather than per currency.
*/
function AddCurrency({ onAdd }: { onAdd: (def: { code: Currency; digits: number; symbol?: string; name?: string }) => void }) {
function AddCurrency({ onAdd }: { onAdd: (def: { code: Currency; symbol?: string; name?: string }) => void }) {
const [code, setCode] = useState("");
const [name, setName] = useState("");
const [symbol, setSymbol] = useState("");
const [digits, setDigits] = useState("2");
const parsedDigits = Number(digits);
const canAdd =
/^[A-Za-z]{2,8}$/.test(code.trim()) &&
Number.isInteger(parsedDigits) &&
parsedDigits >= 0 &&
parsedDigits <= 4;
const canAdd = /^[A-Za-z]{2,8}$/.test(code.trim());
function add() {
onAdd({ code: code.trim(), digits: parsedDigits, symbol, name });
onAdd({ code: code.trim(), symbol, name });
setCode("");
setName("");
setSymbol("");
setDigits("2");
}
return (
@@ -321,23 +334,6 @@ function AddCurrency({ onAdd }: { onAdd: (def: { code: Currency; digits: number;
setSymbol(next);
}}
/>
{/* No visible label. Its three siblings are labelled by placeholder, and
the DECIMALS column heading sits directly above this field in the
table — a fourth label here read as a heading for the whole row. */}
<Input
id="new-digits"
type="number"
min="0"
max="4"
step="1"
aria-label="Decimal places for the new currency"
className="font-money h-9 w-16 text-right"
value={digits}
onChange={(event) => {
const next = event.target.value;
setDigits(next);
}}
/>
<Button variant="outline" disabled={!canAdd} onClick={add}>
Add currency
</Button>