feat: user-defined currencies, and amounts that carry their own currency
Two requests. The second is small; the first changed the money layer.
TYPING THE CURRENCY. The purchase total field now reads "30$" as thirty
dollars, "€25,50" as EUR 25.50, "250 kr" as DKK, "1.234,56 usd" as USD
1234.56, and sets the currency selector from what was typed. Entry speed is a
stated product feature and a second control per amount works against it.
The field is type="text", not type="number": a number input silently discards
anything non-numeric, so "30$" would never reach the parser. It keeps its own
text so a half-typed "30." stays as typed and only commits when it parses.
Separator handling is where parseMoneyInput earns its 26 tests. With both "."
and "," present the LAST is the decimal point, which covers "1.234,56" and
"1,234.56" without knowing a locale. With one, it groups thousands if exactly
three digits follow and is a decimal point otherwise — so "1,500" is fifteen
hundred and "1,5" is one and a half. Spaces group thousands only in
three-digit groups, because "30 40" is two amounts typed by mistake and
committing it as 3040 would be worse than rejecting it. An unrecognised
currency returns null rather than falling back: reading "30 GBP" as 30 DKK
would book money at the wrong currency.
USER-DEFINED CURRENCIES. `Currency` was a closed union and decimal places were
a module constant, so "add a currency" was a code change. Both now live in the
dataset: `Dataset.currencies` is the authority on which codes are valid, what
they are called, their symbol, and how many decimals they round to. Settings
grows a table to add, edit and remove them.
That forced the money layer open. `roundMoney` takes a DIGIT COUNT, not a
currency code, and every one of its ~29 call sites resolves digits from the
dataset via `digitsFor`. Making digits a required number rather than an
optional one with a default of 2 is the whole safety argument: every site
became a compile error the type checker enumerated for me, where a default
would have left each one silently correct for DKK/USD/EUR and silently wrong
for the first zero-decimal currency the owner adds.
Because every existing currency uses two decimals, a broken version of this
refactor would still pass all 356 previous tests. src/domain/currencyDigits.test.ts
is the oracle that cannot lie: a JPY-style zero-decimal currency driven through
roundMoney, both FX directions, both split modes, finance-plan division, the
fold, and the month view, asserting no figure has a fractional yen in it.
Guards that come with it, all tested:
- Codes are identity — they key the FX table and every stored Money — so they
are normalised (" jpy " -> "JPY") and cannot be edited, only added/removed.
- A currency in use cannot be removed, and the refusal names what refers to
it. The same reasoning as posts being archived rather than deleted: stored
Money carries the code, and dangling references would make the import
validator refuse the owner's own exported file. Removing an unused one takes
its exchange rate with it, leaving no orphan row.
- The import validator now checks the currency table itself — codes, duplicate
codes, integer decimals 0-4 — and validates every Money against it. That
boundary check replaces the compile-time safety the closed union provided.
- parseFxResponse takes the accepted codes instead of a global constant, so a
rate service returning dozens cannot put unreachable rows in the table.
Migration v2 -> v3 seeds the currency table. Behaviour-preserving by
construction: every currency that existed had two decimal places and the
seeded table records two, so not one stored amount can round differently than
it did before. An existing `currencies` field is kept rather than overwritten,
since a hand-edited file could already carry currencies the owner added.
Verified in the browser, not just in tests: JPY added through the real Settings
form with 0 decimals, and the five inputs above typed into the real purchase
field, each setting the right currency.
Two obsolete test helpers went away. plans.test.ts and actions.test.ts used to
monkey-patch the CURRENCY_DIGITS constant to prove the table was consulted;
digits are an argument now, so those tests just ask for zero decimals.
types.test.ts no longer asserts "the supported currencies are exactly DKK,
USD, EUR" — that stopped being a property of the code.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9243c8586b
commit
cdece63c68
@@ -45,15 +45,15 @@ describe("blank lines", () => {
|
||||
|
||||
describe("bulkTotal", () => {
|
||||
test("sums the filled lines", () => {
|
||||
expect(bulkTotal(draft({ lines: [line("Milk", 25), line("Bread", 30.5), line("", 0)] }))).toBe(55.5);
|
||||
expect(bulkTotal(draft({ lines: [line("Milk", 25), line("Bread", 30.5), line("", 0)] }), 2)).toBe(55.5);
|
||||
});
|
||||
|
||||
test("is zero when there is nothing filled in", () => {
|
||||
expect(bulkTotal(draft())).toBe(0);
|
||||
expect(bulkTotal(draft(), 2)).toBe(0);
|
||||
});
|
||||
|
||||
test("rounds the sum, so float tails never surface", () => {
|
||||
expect(bulkTotal(draft({ lines: [line("a", 0.1), line("b", 0.2)] }))).toBe(0.3);
|
||||
expect(bulkTotal(draft({ lines: [line("a", 0.1), line("b", 0.2)] }), 2)).toBe(0.3);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+2
-2
@@ -69,10 +69,10 @@ export function filledLines(draft: BulkDraft): BulkLine[] {
|
||||
return draft.lines.filter((line) => !isBlankLine(line));
|
||||
}
|
||||
|
||||
export function bulkTotal(draft: BulkDraft): number {
|
||||
export function bulkTotal(draft: BulkDraft, digits: number): number {
|
||||
return roundMoney(
|
||||
filledLines(draft).reduce((sum, line) => sum + line.amount, 0),
|
||||
draft.currency,
|
||||
digits,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useRef } from "react";
|
||||
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 { formatMoney } from "../format.ts";
|
||||
@@ -24,6 +26,7 @@ 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 descriptionRefs = useRef<Array<HTMLInputElement | null>>([]);
|
||||
const noteRefs = useRef<Array<HTMLInputElement | null>>([]);
|
||||
|
||||
@@ -125,7 +128,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), draft.currency)}</span>
|
||||
<span className="font-money">{formatMoney(bulkTotal(draft, digits), draft.currency)}</span>
|
||||
</p>
|
||||
</fieldset>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
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";
|
||||
@@ -28,7 +30,10 @@ function hasValidDate(draft: PurchaseDraft): boolean {
|
||||
}
|
||||
|
||||
export function PlanEditor({ draft, onChange }: Props) {
|
||||
const balance = planBalance(draft);
|
||||
// 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 balance = planBalance(draft, digits);
|
||||
|
||||
if (!draft.plan) {
|
||||
const dateReady = hasValidDate(draft);
|
||||
@@ -41,7 +46,7 @@ export function PlanEditor({ draft, onChange }: Props) {
|
||||
disabled={!dateReady}
|
||||
onClick={() => {
|
||||
if (!dateReady) return;
|
||||
onChange(withPlan(draft, monthOf(draft.date), 3));
|
||||
onChange(withPlan(draft, monthOf(draft.date), 3, digits));
|
||||
}}
|
||||
>
|
||||
Spread this over several months
|
||||
@@ -70,7 +75,7 @@ export function PlanEditor({ draft, onChange }: Props) {
|
||||
onChange={(event) => {
|
||||
const months = Number(event.target.value);
|
||||
if (Number.isInteger(months) && months >= 1) {
|
||||
onChange(withPlan(draft, draft.plan!.startMonth, months));
|
||||
onChange(withPlan(draft, draft.plan!.startMonth, months, digits));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -27,9 +27,10 @@ import {
|
||||
type BulkDraft,
|
||||
} from "../bulkEntry.ts";
|
||||
import { useDataset } from "../hooks/useDataset.ts";
|
||||
import { parseMoneyInput } from "../moneyInput.ts";
|
||||
import { useMutate } from "../hooks/useMutate.ts";
|
||||
import { addPurchase, cancelScheduleFrom, updatePurchase } from "../../store/actions.ts";
|
||||
import { CURRENCIES, type MonthId, type Purchase } from "../../domain/types.ts";
|
||||
import type { MonthId, Purchase } from "../../domain/types.ts";
|
||||
|
||||
interface Props {
|
||||
monthId: MonthId;
|
||||
@@ -49,6 +50,11 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
|
||||
const [draft, setDraft] = useState<PurchaseDraft>(() =>
|
||||
purchase ? fromPurchase(purchase) : emptyDraft(monthId, activePosts[0]?.id ?? ""),
|
||||
);
|
||||
// The amount field keeps its own text so a half-typed "30." or "30 " stays
|
||||
// exactly as typed; `draft.amount` only moves when the text parses.
|
||||
const [amountText, setAmountText] = useState(() =>
|
||||
purchase ? String(purchase.total.amount) : "",
|
||||
);
|
||||
// "many" is only meaningful when adding — editing an existing purchase edits
|
||||
// exactly one, so the toggle is hidden in that case.
|
||||
const [mode, setMode] = useState<"one" | "many">("one");
|
||||
@@ -179,7 +185,7 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
|
||||
setBulk((b) => ({ ...b, currency }));
|
||||
}}
|
||||
>
|
||||
{CURRENCIES.map((currency) => (
|
||||
{dataset.currencies.map(({ code: currency }) => (
|
||||
<option key={currency} value={currency}>
|
||||
{currency}
|
||||
</option>
|
||||
@@ -247,15 +253,24 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="purchase-amount">Total</Label>
|
||||
{/* type="text", not "number": a number input silently discards
|
||||
anything non-numeric, so "30$" would never reach the parser.
|
||||
Entry speed is the point — the currency is set from what was
|
||||
typed rather than from a second control. */}
|
||||
<Input
|
||||
id="purchase-amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
inputMode="decimal"
|
||||
className="font-money"
|
||||
value={draft.amount}
|
||||
onChange={(event) =>
|
||||
setDraft({ ...draft, amount: Number(event.target.value) || 0 })
|
||||
}
|
||||
placeholder="30$"
|
||||
value={amountText}
|
||||
onChange={(event) => {
|
||||
const typed = event.target.value;
|
||||
setAmountText(typed);
|
||||
const parsed = parseMoneyInput(typed, dataset.currencies, draft.currency);
|
||||
if (parsed) {
|
||||
setDraft({ ...draft, amount: parsed.amount, currency: parsed.currency });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
@@ -268,7 +283,7 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
|
||||
setDraft({ ...draft, currency: event.target.value as typeof draft.currency })
|
||||
}
|
||||
>
|
||||
{CURRENCIES.map((currency) => (
|
||||
{dataset.currencies.map(({ code: currency }) => (
|
||||
<option key={currency} value={currency}>
|
||||
{currency}
|
||||
</option>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
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";
|
||||
@@ -13,7 +15,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export function SplitEditor({ draft, posts, onChange }: Props) {
|
||||
const balance = splitBalance(draft);
|
||||
const balance = splitBalance(draft, digitsFor(useDataset().currencies, draft.currency));
|
||||
const unit = draft.splitMode === "percent" ? "%" : draft.currency;
|
||||
|
||||
function updateSplit(index: number, changes: Partial<SplitDraft>) {
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { test, expect, describe } from "bun:test";
|
||||
import { parseMoneyInput, type CurrencyOption } from "./moneyInput.ts";
|
||||
|
||||
const OPTIONS: CurrencyOption[] = [
|
||||
{ code: "DKK", symbol: "kr" },
|
||||
{ code: "USD", symbol: "$" },
|
||||
{ code: "EUR", symbol: "€" },
|
||||
];
|
||||
const parse = (text: string, fallback = "DKK") =>
|
||||
parseMoneyInput(text, OPTIONS, fallback);
|
||||
|
||||
describe("reading the currency out of what was typed", () => {
|
||||
test("a trailing symbol", () => {
|
||||
expect(parse("30$")).toEqual({ amount: 30, currency: "USD" });
|
||||
});
|
||||
|
||||
test("a leading symbol", () => {
|
||||
expect(parse("$30")).toEqual({ amount: 30, currency: "USD" });
|
||||
});
|
||||
|
||||
test("a symbol with a space either side", () => {
|
||||
expect(parse("30 $")).toEqual({ amount: 30, currency: "USD" });
|
||||
expect(parse("$ 30")).toEqual({ amount: 30, currency: "USD" });
|
||||
});
|
||||
|
||||
test("a three-letter code, in any case", () => {
|
||||
expect(parse("30 usd")).toEqual({ amount: 30, currency: "USD" });
|
||||
expect(parse("USD 30")).toEqual({ amount: 30, currency: "USD" });
|
||||
expect(parse("30EUR")).toEqual({ amount: 30, currency: "EUR" });
|
||||
});
|
||||
|
||||
test("a multi-letter symbol", () => {
|
||||
expect(parse("250 kr")).toEqual({ amount: 250, currency: "DKK" });
|
||||
expect(parse("250kr")).toEqual({ amount: 250, currency: "DKK" });
|
||||
});
|
||||
|
||||
test("no currency at all falls back to the one the field is in", () => {
|
||||
expect(parse("30")).toEqual({ amount: 30, currency: "DKK" });
|
||||
expect(parse("30", "EUR")).toEqual({ amount: 30, currency: "EUR" });
|
||||
});
|
||||
|
||||
test("a code wins over a symbol when both appear", () => {
|
||||
// "$30 USD" is contradictory; the explicit code is the stronger signal.
|
||||
expect(parse("$30 USD")).toEqual({ amount: 30, currency: "USD" });
|
||||
});
|
||||
|
||||
test("an unknown currency is rejected rather than silently ignored", () => {
|
||||
// Silently dropping "GBP" would book a GBP purchase as DKK.
|
||||
expect(parse("30 GBP")).toBeNull();
|
||||
expect(parse("30 £")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("reading the number out of what was typed", () => {
|
||||
test("plain decimals", () => {
|
||||
expect(parse("30.50")).toEqual({ amount: 30.5, currency: "DKK" });
|
||||
});
|
||||
|
||||
test("a comma decimal separator, as most of Europe writes it", () => {
|
||||
expect(parse("30,50")).toEqual({ amount: 30.5, currency: "DKK" });
|
||||
expect(parse("€25,50")).toEqual({ amount: 25.5, currency: "EUR" });
|
||||
});
|
||||
|
||||
/**
|
||||
* The genuinely ambiguous cases. The rule: when both separators appear, the
|
||||
* LAST one is the decimal point. When only one appears, it is a thousands
|
||||
* separator if exactly three digits follow it, and a decimal point
|
||||
* otherwise. "1,500" is therefore one thousand five hundred, which is what
|
||||
* someone typing a budget figure means.
|
||||
*/
|
||||
test("both separators: the last one is the decimal point", () => {
|
||||
expect(parse("1.234,56")).toEqual({ amount: 1234.56, currency: "DKK" });
|
||||
expect(parse("1,234.56")).toEqual({ amount: 1234.56, currency: "DKK" });
|
||||
});
|
||||
|
||||
test("one separator with three digits after it groups thousands", () => {
|
||||
expect(parse("1,500")).toEqual({ amount: 1500, currency: "DKK" });
|
||||
expect(parse("1.500")).toEqual({ amount: 1500, currency: "DKK" });
|
||||
});
|
||||
|
||||
test("one separator with one or two digits after it is a decimal point", () => {
|
||||
expect(parse("1,5")).toEqual({ amount: 1.5, currency: "DKK" });
|
||||
expect(parse("1.50")).toEqual({ amount: 1.5, currency: "DKK" });
|
||||
});
|
||||
|
||||
test("spaces and non-breaking spaces group thousands", () => {
|
||||
expect(parse("28 000")).toEqual({ amount: 28000, currency: "DKK" });
|
||||
expect(parse("28 000,50")).toEqual({ amount: 28000.5, currency: "DKK" });
|
||||
});
|
||||
|
||||
test("a negative amount is legal — a refund is a normal line", () => {
|
||||
expect(parse("-500")).toEqual({ amount: -500, currency: "DKK" });
|
||||
expect(parse("-30$")).toEqual({ amount: -30, currency: "USD" });
|
||||
expect(parse("$-30")).toEqual({ amount: -30, currency: "USD" });
|
||||
});
|
||||
|
||||
test("a leading plus is accepted and means nothing", () => {
|
||||
expect(parse("+30")).toEqual({ amount: 30, currency: "DKK" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("what is not a money value", () => {
|
||||
test.each([
|
||||
["empty", ""],
|
||||
["only spaces", " "],
|
||||
["only a currency", "$"],
|
||||
["only a code", "USD"],
|
||||
["letters", "abc"],
|
||||
["two numbers", "30 40"],
|
||||
["a bare separator", ","],
|
||||
["a lone minus", "-"],
|
||||
])("%s is not a money value", (_label, text) => {
|
||||
expect(parse(text)).toBeNull();
|
||||
});
|
||||
|
||||
test("a half-typed decimal is not yet a value, and must not become one", () => {
|
||||
// Committing "30." as 30 would fight the user mid-keystroke.
|
||||
expect(parse("30.")).toBeNull();
|
||||
expect(parse("30,")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("symbols that collide", () => {
|
||||
const withCollision: CurrencyOption[] = [
|
||||
{ code: "USD", symbol: "$" },
|
||||
{ code: "CAD", symbol: "$" },
|
||||
];
|
||||
|
||||
test("the first currency defined with that symbol wins, and the code disambiguates", () => {
|
||||
expect(parseMoneyInput("30$", withCollision, "USD")).toEqual({
|
||||
amount: 30,
|
||||
currency: "USD",
|
||||
});
|
||||
expect(parseMoneyInput("30 CAD", withCollision, "USD")).toEqual({
|
||||
amount: 30,
|
||||
currency: "CAD",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { Currency } from "../domain/types.ts";
|
||||
|
||||
export interface CurrencyOption {
|
||||
code: Currency;
|
||||
/** "$", "kr", "€". Optional: a currency can be entered by code alone. */
|
||||
symbol?: string;
|
||||
}
|
||||
|
||||
export interface ParsedMoney {
|
||||
amount: number;
|
||||
currency: Currency;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads an amount AND its currency out of one field, so "30$" means thirty
|
||||
* dollars without touching a separate dropdown. Entry speed is a stated
|
||||
* product feature; a second control per amount is the opposite of it.
|
||||
*
|
||||
* Returns null for anything that is not yet a money value, including a
|
||||
* half-typed "30." — committing that as 30 would fight the user mid-keystroke.
|
||||
* An unrecognised currency also returns null rather than falling back, because
|
||||
* silently reading "30 GBP" as 30 DKK books money at the wrong currency.
|
||||
*/
|
||||
export function parseMoneyInput(
|
||||
text: string,
|
||||
options: readonly CurrencyOption[],
|
||||
fallback: Currency,
|
||||
): ParsedMoney | null {
|
||||
const trimmed = text.trim();
|
||||
if (trimmed === "") return null;
|
||||
|
||||
const currency = detectCurrency(trimmed, options);
|
||||
if (currency === "unknown") return null;
|
||||
|
||||
const amount = parseAmount(stripCurrency(trimmed, options));
|
||||
if (amount === null) return null;
|
||||
|
||||
return { amount, currency: currency ?? fallback };
|
||||
}
|
||||
|
||||
/**
|
||||
* A three-letter code beats a symbol: "$30 USD" is contradictory, and the code
|
||||
* is the more deliberate signal. Returns `null` when no currency was written
|
||||
* at all, and "unknown" when something currency-shaped was written that this
|
||||
* dataset has no currency for.
|
||||
*/
|
||||
function detectCurrency(
|
||||
text: string,
|
||||
options: readonly CurrencyOption[],
|
||||
): Currency | null | "unknown" {
|
||||
const codes = new Map(options.map((o) => [o.code.toUpperCase(), o.code]));
|
||||
|
||||
// A bare three-letter word, not part of a longer word.
|
||||
const words = text.toUpperCase().match(/[A-Z]{2,}/g) ?? [];
|
||||
for (const word of words) {
|
||||
const matched = codes.get(word);
|
||||
if (matched) return matched;
|
||||
}
|
||||
|
||||
// Longest symbol first, so "kr." cannot be shadowed by "kr".
|
||||
const symbols = options
|
||||
.filter((o) => o.symbol)
|
||||
.sort((a, b) => b.symbol!.length - a.symbol!.length);
|
||||
for (const option of symbols) {
|
||||
if (text.includes(option.symbol!)) return option.code;
|
||||
}
|
||||
|
||||
// Anything left that is neither digits, separators nor a sign is a currency
|
||||
// this dataset does not define.
|
||||
if (/[^\d\s.,+-]/.test(text)) return "unknown";
|
||||
|
||||
// A word that is not a known code is unknown too ("30 GBP").
|
||||
if (words.length > 0) return "unknown";
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function stripCurrency(text: string, options: readonly CurrencyOption[]): string {
|
||||
let out = text;
|
||||
for (const option of options) {
|
||||
if (option.symbol) out = out.split(option.symbol).join(" ");
|
||||
out = out.replace(new RegExp(option.code, "gi"), " ");
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Separator handling, which is where this earns its tests. When BOTH "." and
|
||||
* "," appear, the last one is the decimal point — that covers "1.234,56" and
|
||||
* "1,234.56" without knowing the user's locale. When only one appears, it
|
||||
* groups thousands if exactly three digits follow and is a decimal point
|
||||
* otherwise, so "1,500" is fifteen hundred and "1,5" is one and a half.
|
||||
*/
|
||||
function parseAmount(text: string): number | null {
|
||||
// Normalise every kind of space to a plain one before splitting.
|
||||
const spaced = text.replace(/\s+/g, " ").trim();
|
||||
if (spaced === "") return null;
|
||||
|
||||
const sign = spaced.startsWith("-") ? -1 : 1;
|
||||
const unsigned = spaced.replace(/^[+-]\s*/, "");
|
||||
|
||||
// A space only ever GROUPS thousands, so every group after the first must be
|
||||
// exactly three digits. Without this, "30 40" silently becomes 3040 — two
|
||||
// amounts typed by mistake, committed as one much larger one.
|
||||
const groups = unsigned.split(" ").filter((group) => group !== "");
|
||||
if (groups.length === 0) return null;
|
||||
if (groups.length > 1) {
|
||||
const allGrouped = groups
|
||||
.slice(1)
|
||||
.every((group) => /^\d{3}([.,]\d+)?$/.test(group));
|
||||
if (!allGrouped) return null;
|
||||
}
|
||||
|
||||
const digits = groups.join("");
|
||||
if (!/^[\d.,]+$/.test(digits)) return null;
|
||||
if (/^[.,]+$/.test(digits)) return null;
|
||||
// A trailing separator means the number is still being typed.
|
||||
if (/[.,]$/.test(digits)) return null;
|
||||
|
||||
const lastDot = digits.lastIndexOf(".");
|
||||
const lastComma = digits.lastIndexOf(",");
|
||||
let decimalAt = -1;
|
||||
|
||||
if (lastDot >= 0 && lastComma >= 0) {
|
||||
decimalAt = Math.max(lastDot, lastComma);
|
||||
} else if (lastDot >= 0 || lastComma >= 0) {
|
||||
const only = Math.max(lastDot, lastComma);
|
||||
if (digits.length - only - 1 !== 3) decimalAt = only;
|
||||
}
|
||||
|
||||
const whole = (decimalAt === -1 ? digits : digits.slice(0, decimalAt)).replace(
|
||||
/[.,]/g,
|
||||
"",
|
||||
);
|
||||
const fraction = decimalAt === -1 ? "" : digits.slice(decimalAt + 1);
|
||||
if (/[.,]/.test(fraction)) return null;
|
||||
|
||||
const value = Number(
|
||||
`${whole === "" ? "0" : whole}.${fraction === "" ? "0" : fraction}`,
|
||||
);
|
||||
return Number.isFinite(value) ? sign * value : null;
|
||||
}
|
||||
|
||||
+14
-13
@@ -30,7 +30,7 @@ describe("emptyDraft", () => {
|
||||
|
||||
describe("splitBalance", () => {
|
||||
test("is zero when percentages total 100", () => {
|
||||
expect(splitBalance(draft())).toBe(0);
|
||||
expect(splitBalance(draft(), 2)).toBe(0);
|
||||
});
|
||||
|
||||
test("reports the missing percentage", () => {
|
||||
@@ -40,7 +40,7 @@ describe("splitBalance", () => {
|
||||
{ postId: "events", value: 30, absorbsRemainder: false },
|
||||
],
|
||||
});
|
||||
expect(splitBalance(d)).toBe(10);
|
||||
expect(splitBalance(d, 2)).toBe(10);
|
||||
});
|
||||
|
||||
test("reports the missing amount in fixed mode", () => {
|
||||
@@ -51,7 +51,7 @@ describe("splitBalance", () => {
|
||||
{ postId: "events", value: 50, absorbsRemainder: false },
|
||||
],
|
||||
});
|
||||
expect(splitBalance(d)).toBe(30);
|
||||
expect(splitBalance(d, 2)).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -123,7 +123,7 @@ describe("toPurchase and fromPurchase", () => {
|
||||
|
||||
describe("finance plans", () => {
|
||||
test("withPlan generates equal slices from the given month", () => {
|
||||
const d = withPlan(draft({ amount: 3000 }), "2026-10", 6);
|
||||
const d = withPlan(draft({ amount: 3000 }), "2026-10", 6, 2);
|
||||
expect(d.plan!.slices).toHaveLength(6);
|
||||
expect(d.plan!.slices[0]).toEqual({
|
||||
month: "2026-10",
|
||||
@@ -140,37 +140,37 @@ describe("finance plans", () => {
|
||||
{ postId: "events", value: 30, absorbsRemainder: false },
|
||||
],
|
||||
});
|
||||
expect(withPlan(split, "2026-10", 2).splits).toEqual(split.splits);
|
||||
expect(withPlan(split, "2026-10", 2, 2).splits).toEqual(split.splits);
|
||||
});
|
||||
|
||||
test("withoutPlan clears the schedule", () => {
|
||||
expect(withoutPlan(withPlan(draft(), "2026-10", 3)).plan).toBeNull();
|
||||
expect(withoutPlan(withPlan(draft(), "2026-10", 3, 2)).plan).toBeNull();
|
||||
});
|
||||
|
||||
test("setSliceAmount edits one slice and leaves the others alone", () => {
|
||||
const d = setSliceAmount(withPlan(draft({ amount: 3000 }), "2026-10", 3), 0, 1500);
|
||||
const d = setSliceAmount(withPlan(draft({ amount: 3000 }), "2026-10", 3, 2), 0, 1500);
|
||||
expect(d.plan!.slices.map((s) => s.amount.amount)).toEqual([1500, 1000, 1000]);
|
||||
});
|
||||
|
||||
test("planBalance reports the difference between the slices and the total", () => {
|
||||
const even = withPlan(draft({ amount: 3000 }), "2026-10", 3);
|
||||
expect(planBalance(even)).toBe(0);
|
||||
expect(planBalance(setSliceAmount(even, 0, 1500))).toBe(-500);
|
||||
const even = withPlan(draft({ amount: 3000 }), "2026-10", 3, 2);
|
||||
expect(planBalance(even, 2)).toBe(0);
|
||||
expect(planBalance(setSliceAmount(even, 0, 1500), 2)).toBe(-500);
|
||||
});
|
||||
|
||||
test("planBalance is zero when there is no plan", () => {
|
||||
expect(planBalance(draft())).toBe(0);
|
||||
expect(planBalance(draft(), 2)).toBe(0);
|
||||
});
|
||||
|
||||
test("a plan with unbalanced slices is still valid", () => {
|
||||
// A deposit-then-instalments plan may legitimately not sum to the total
|
||||
// yet while the user is typing; the editor warns rather than blocking.
|
||||
const d = setSliceAmount(withPlan(draft({ amount: 3000 }), "2026-10", 3), 0, 1500);
|
||||
const d = setSliceAmount(withPlan(draft({ amount: 3000 }), "2026-10", 3, 2), 0, 1500);
|
||||
expect(validatePurchase(d)).toEqual([]);
|
||||
});
|
||||
|
||||
test("a plan with zero months is rejected", () => {
|
||||
expect(() => withPlan(draft(), "2026-10", 0)).toThrow(/at least one month/i);
|
||||
expect(() => withPlan(draft(), "2026-10", 0, 2)).toThrow(/at least one month/i);
|
||||
});
|
||||
|
||||
test("split×plan composition: each slice divides across posts in the split ratio", () => {
|
||||
@@ -189,6 +189,7 @@ describe("finance plans", () => {
|
||||
}),
|
||||
"2026-10",
|
||||
3,
|
||||
2,
|
||||
);
|
||||
expect(composed.splits).toEqual([
|
||||
{ postId: "games", value: 70, absorbsRemainder: true },
|
||||
|
||||
+15
-5
@@ -45,10 +45,10 @@ export function emptyDraft(monthId: MonthId, postId: string): PurchaseDraft {
|
||||
}
|
||||
|
||||
/** What the split editor must show: how far the parts are from the whole. */
|
||||
export function splitBalance(draft: PurchaseDraft): number {
|
||||
export function splitBalance(draft: PurchaseDraft, digits: number): number {
|
||||
const target = draft.splitMode === "percent" ? 100 : draft.amount;
|
||||
const sum = draft.splits.reduce((total, split) => total + split.value, 0);
|
||||
return roundMoney(target - sum, draft.currency);
|
||||
return roundMoney(target - sum, digits);
|
||||
}
|
||||
|
||||
export function validatePurchase(draft: PurchaseDraft): string[] {
|
||||
@@ -104,12 +104,18 @@ export function withPlan(
|
||||
draft: PurchaseDraft,
|
||||
startMonth: MonthId,
|
||||
months: number,
|
||||
digits: number,
|
||||
): PurchaseDraft {
|
||||
return {
|
||||
...draft,
|
||||
plan: {
|
||||
startMonth,
|
||||
slices: equalSlices({ amount: draft.amount, currency: draft.currency }, startMonth, months),
|
||||
slices: equalSlices(
|
||||
{ amount: draft.amount, currency: draft.currency },
|
||||
startMonth,
|
||||
months,
|
||||
digits,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -140,9 +146,13 @@ export function setSliceAmount(
|
||||
* slices and compare to the total" arithmetic has exactly one implementation.
|
||||
* Positive means the slices fall short of the purchase total.
|
||||
*/
|
||||
export function planBalance(draft: PurchaseDraft): number {
|
||||
export function planBalance(draft: PurchaseDraft, digits: number): number {
|
||||
if (!draft.plan) return 0;
|
||||
return slicesBalance({ amount: draft.amount, currency: draft.currency }, draft.plan.slices);
|
||||
return slicesBalance(
|
||||
{ amount: draft.amount, currency: draft.currency },
|
||||
draft.plan.slices,
|
||||
digits,
|
||||
);
|
||||
}
|
||||
|
||||
export function fromPurchase(purchase: Purchase): PurchaseDraft {
|
||||
|
||||
@@ -15,7 +15,13 @@ export function PostMonthRoute() {
|
||||
const row = monthView(dataset, monthId).rows.find((r) => r.post.id === postId);
|
||||
|
||||
const thisMonth = dataset.purchases.flatMap((purchase) => {
|
||||
const charges = chargesForPurchaseInMonth(purchase, monthId, base, dataset.fxRates);
|
||||
const charges = chargesForPurchaseInMonth(
|
||||
purchase,
|
||||
monthId,
|
||||
base,
|
||||
dataset.fxRates,
|
||||
dataset.currencies,
|
||||
);
|
||||
const mine = charges.find((c) => c.postId === postId);
|
||||
return mine ? [{ purchase, amount: mine.amount }] : [];
|
||||
});
|
||||
|
||||
@@ -5,9 +5,17 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useDataset } from "../../hooks/useDataset.ts";
|
||||
import { useMutate } from "../../hooks/useMutate.ts";
|
||||
import { setBaseCurrency, setFxRate, removeFxRate } from "../../../store/actions.ts";
|
||||
import {
|
||||
addCurrency,
|
||||
removeCurrency,
|
||||
setBaseCurrency,
|
||||
setFxRate,
|
||||
removeFxRate,
|
||||
updateCurrency,
|
||||
} from "../../../store/actions.ts";
|
||||
import { DEFAULT_FX_API_URL, fetchFxRates } from "../../../store/fxApi.ts";
|
||||
import { CURRENCIES, type Currency } from "../../../domain/types.ts";
|
||||
import { currencyUsage } from "../../../domain/currencies.ts";
|
||||
import type { Currency } from "../../../domain/types.ts";
|
||||
|
||||
export function CurrencySection() {
|
||||
const dataset = useDataset();
|
||||
@@ -15,7 +23,8 @@ export function CurrencySection() {
|
||||
const base = dataset.settings.baseCurrency;
|
||||
const apiUrl = dataset.settings.fxApiUrl ?? DEFAULT_FX_API_URL;
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const targets = CURRENCIES.filter((c) => c !== base);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const targets = dataset.currencies.map((c) => c.code).filter((c) => c !== base);
|
||||
|
||||
async function refresh() {
|
||||
setStatus("Fetching…");
|
||||
@@ -24,94 +33,198 @@ export function CurrencySection() {
|
||||
mutate((draft) => {
|
||||
for (const rate of rates) setFxRate(draft, rate);
|
||||
});
|
||||
setStatus(`Updated ${rates.length} rate(s).`);
|
||||
} catch (error) {
|
||||
const missing = targets.filter((t) => !rates.some((r) => r.currency === t));
|
||||
setStatus(
|
||||
`Updated ${rates.length} rate(s).` +
|
||||
(missing.length > 0
|
||||
? ` The service returned nothing for ${missing.join(", ")} — type those in by hand.`
|
||||
: ""),
|
||||
);
|
||||
} catch (cause) {
|
||||
// Fails soft: the cached rates are still in place, never cleared.
|
||||
setStatus(
|
||||
`Could not fetch rates (${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
cause instanceof Error ? cause.message : String(cause)
|
||||
}). Your saved rates are unchanged.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title="Currency">
|
||||
/** Currency edits throw on bad input, and a throw from a handler is invisible. */
|
||||
function tryEdit(change: () => void) {
|
||||
try {
|
||||
setError(null);
|
||||
change();
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : String(cause));
|
||||
}
|
||||
}
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="base-currency">Base currency (all totals use this)</Label>
|
||||
return (
|
||||
<Section
|
||||
title="Currencies"
|
||||
hint="Add any currency you spend in. The code is how it is identified, so it cannot be changed later — and everything is converted to the base currency for totals."
|
||||
>
|
||||
{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>
|
||||
<select
|
||||
id="base-currency"
|
||||
className="h-9 w-32 rounded border bg-background px-2 text-sm"
|
||||
className="h-9 w-40 rounded-md border border-input bg-background px-2 text-sm"
|
||||
value={base}
|
||||
onChange={(event) => {
|
||||
const currency = event.target.value as Currency;
|
||||
const currency = event.target.value;
|
||||
mutate((draft) => setBaseCurrency(draft, currency));
|
||||
}}
|
||||
>
|
||||
{CURRENCIES.map((currency) => (
|
||||
<option key={currency} value={currency}>
|
||||
{currency}
|
||||
{dataset.currencies.map(({ code }) => (
|
||||
<option key={code} value={code}>
|
||||
{code}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<table className="w-full max-w-md text-sm">
|
||||
<thead className="text-left">
|
||||
<tr className="border-b border-budget-rule text-[0.6875rem] uppercase tracking-wider text-budget-ink-muted">
|
||||
<th className="py-2">Currency</th>
|
||||
<th className="py-2 text-right">1 unit = ? {base}</th>
|
||||
<th className="py-2 text-right">Source</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{targets.map((currency) => {
|
||||
const rate = dataset.fxRates.find((r) => r.currency === currency);
|
||||
return (
|
||||
<tr key={currency} className="border-b last:border-0">
|
||||
<td className="py-2">{currency}</td>
|
||||
<td className="py-2 text-right">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.000001"
|
||||
className="font-money ml-auto h-8 w-32"
|
||||
value={rate?.baseUnitsPerOne ?? ""}
|
||||
onChange={(event) => {
|
||||
// A cleared or non-positive field MUST remove the rate
|
||||
// row rather than store 0: toBase() converts a stored 0
|
||||
// silently to a 0-value conversion, while an absent row
|
||||
// correctly throws MissingRateError. Storing 0 here
|
||||
// would turn a loud, correct error into wrong numbers
|
||||
// presented as fact.
|
||||
const parsed = Number(event.target.value);
|
||||
mutate((draft) => {
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
setFxRate(draft, {
|
||||
currency,
|
||||
baseUnitsPerOne: parsed,
|
||||
updatedAt: new Date().toISOString(),
|
||||
source: "manual",
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[42rem] text-sm">
|
||||
<thead className="text-left">
|
||||
<tr className="border-b border-budget-rule text-[0.6875rem] uppercase tracking-wider text-budget-ink-muted">
|
||||
<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" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{dataset.currencies.map((currency) => {
|
||||
const isBase = currency.code === base;
|
||||
const rate = dataset.fxRates.find((r) => r.currency === currency.code);
|
||||
const used = currencyUsage(dataset, currency.code);
|
||||
return (
|
||||
<tr
|
||||
key={currency.code}
|
||||
className="group border-b border-budget-rule transition-colors last:border-0 hover:bg-accent/60"
|
||||
>
|
||||
<td className="font-money py-2 font-medium">{currency.code}</td>
|
||||
<td className="py-2 pl-4">
|
||||
<Input
|
||||
className="h-8 w-40"
|
||||
aria-label={`Name for ${currency.code}`}
|
||||
value={currency.name ?? ""}
|
||||
onChange={(event) => {
|
||||
const name = event.target.value;
|
||||
mutate((draft) => updateCurrency(draft, currency.code, { name }));
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pl-4">
|
||||
<Input
|
||||
className="font-money h-8 w-16"
|
||||
aria-label={`Symbol for ${currency.code}`}
|
||||
value={currency.symbol ?? ""}
|
||||
onChange={(event) => {
|
||||
const symbol = event.target.value;
|
||||
mutate((draft) => updateCurrency(draft, currency.code, { symbol }));
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
) : (
|
||||
<Input
|
||||
type="number"
|
||||
step="0.000001"
|
||||
className="font-money ml-auto h-8 w-32 text-right"
|
||||
aria-label={`Rate for ${currency.code}`}
|
||||
value={rate?.baseUnitsPerOne ?? ""}
|
||||
onChange={(event) => {
|
||||
// A cleared or non-positive field MUST remove the rate
|
||||
// row rather than store 0: toBase() converts a stored 0
|
||||
// silently to a 0-value conversion, while an absent row
|
||||
// correctly throws MissingRateError. Storing 0 here
|
||||
// would turn a loud, correct error into wrong numbers
|
||||
// presented as fact.
|
||||
const parsed = Number(event.target.value);
|
||||
mutate((draft) => {
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
setFxRate(draft, {
|
||||
currency: currency.code,
|
||||
baseUnitsPerOne: parsed,
|
||||
updatedAt: new Date().toISOString(),
|
||||
source: "manual",
|
||||
});
|
||||
} else {
|
||||
removeFxRate(draft, currency.code);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
removeFxRate(draft, currency);
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 text-right text-xs text-muted-foreground">
|
||||
{rate ? `${rate.source} · ${rate.updatedAt.slice(0, 10)}` : "not set"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pl-4 text-right text-xs text-budget-ink-muted">
|
||||
{isBase ? "—" : rate ? `${rate.source} · ${rate.updatedAt.slice(0, 10)}` : "not set"}
|
||||
</td>
|
||||
<td className="py-2 pl-4 text-right">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-overspend hover:text-overspend disabled:opacity-40"
|
||||
disabled={used.length > 0}
|
||||
title={
|
||||
used.length > 0
|
||||
? `In use by ${used.slice(0, 3).join(", ")}${used.length > 3 ? ` and ${used.length - 3} more` : ""}`
|
||||
: `Remove ${currency.code}`
|
||||
}
|
||||
onClick={() =>
|
||||
tryEdit(() => mutate((draft) => removeCurrency(draft, currency.code)))
|
||||
}
|
||||
>
|
||||
remove
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="fx-url">Rate service URL</Label>
|
||||
<AddCurrency onAdd={(def) => tryEdit(() => mutate((draft) => addCurrency(draft, def)))} />
|
||||
|
||||
<div className="mt-5 space-y-2 border-t border-budget-rule pt-5">
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="fx-url"
|
||||
className="text-[0.6875rem] font-medium uppercase tracking-wider text-budget-ink-muted"
|
||||
>
|
||||
Rate service URL
|
||||
</Label>
|
||||
<Input
|
||||
id="fx-url"
|
||||
className="max-w-xl"
|
||||
@@ -123,16 +236,102 @@ export function CurrencySection() {
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-xs text-budget-ink-muted">
|
||||
{"{base}"} and {"{targets}"} are substituted. Fetching is optional — the app
|
||||
works offline with the rates you type in above.
|
||||
works offline with the rates you type in above, and a currency the service
|
||||
does not know can always be maintained by hand.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => void refresh()}>
|
||||
Fetch rates now
|
||||
</Button>
|
||||
{status && <p className="text-xs">{status}</p>}
|
||||
{status && <p className="text-xs text-budget-ink-muted">{status}</p>}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
function AddCurrency({ onAdd }: { onAdd: (def: { code: Currency; digits: number; 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;
|
||||
|
||||
function add() {
|
||||
onAdd({ code: code.trim(), digits: parsedDigits, symbol, name });
|
||||
setCode("");
|
||||
setName("");
|
||||
setSymbol("");
|
||||
setDigits("2");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-4 flex flex-wrap items-end gap-2">
|
||||
<Input
|
||||
className="font-money h-9 w-24 uppercase"
|
||||
placeholder="JPY"
|
||||
aria-label="New currency code"
|
||||
value={code}
|
||||
onChange={(event) => {
|
||||
const next = event.target.value;
|
||||
setCode(next);
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
className="h-9 w-44"
|
||||
placeholder="Japanese yen"
|
||||
aria-label="New currency name"
|
||||
value={name}
|
||||
onChange={(event) => {
|
||||
const next = event.target.value;
|
||||
setName(next);
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
className="font-money h-9 w-16"
|
||||
placeholder="¥"
|
||||
aria-label="New currency symbol"
|
||||
value={symbol}
|
||||
onChange={(event) => {
|
||||
const next = event.target.value;
|
||||
setSymbol(next);
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Label
|
||||
htmlFor="new-digits"
|
||||
className="text-[0.6875rem] font-medium uppercase tracking-wider text-budget-ink-muted"
|
||||
>
|
||||
Decimals
|
||||
</Label>
|
||||
<Input
|
||||
id="new-digits"
|
||||
type="number"
|
||||
min="0"
|
||||
max="4"
|
||||
step="1"
|
||||
className="font-money h-9 w-16 text-right"
|
||||
value={digits}
|
||||
onChange={(event) => {
|
||||
const next = event.target.value;
|
||||
setDigits(next);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" disabled={!canAdd} onClick={add}>
|
||||
Add currency
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { useDataset } from "../../hooks/useDataset.ts";
|
||||
import { useMutate } from "../../hooks/useMutate.ts";
|
||||
import { addPost, movePost, setPostArchived, updatePost } from "../../../store/actions.ts";
|
||||
import { CURRENCIES, type Currency, type Post } from "../../../domain/types.ts";
|
||||
import type { Currency, Post } from "../../../domain/types.ts";
|
||||
import { ruleAt } from "../../../domain/allocation.ts";
|
||||
import { currentMonth } from "../../../store/index.ts";
|
||||
import { RuleHistory } from "../../components/RuleHistory.tsx";
|
||||
@@ -72,7 +72,7 @@ export function PostsSection() {
|
||||
mutate((draft) => updatePost(draft, post.id, { currency }));
|
||||
}}
|
||||
>
|
||||
{CURRENCIES.map((currency) => (
|
||||
{dataset.currencies.map(({ code: currency }) => (
|
||||
<option key={currency} value={currency}>
|
||||
{currency}
|
||||
</option>
|
||||
|
||||
Reference in New Issue
Block a user