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:
co-authored by
Claude Opus 5
parent
ebef56eef4
commit
8d2b2ca881
@@ -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"} ·{" "}
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user