feat: refine month totals and recurring purchase entry
Continuous Integration / lint-and-test (push) Successful in 29s
Continuous Integration / docker-build-and-push (push) Successful in 40s

This commit is contained in:
Lucas Winther committed 2026-10-03 16:40:00 +02:00
1 parent 7cccd7a41a
commit c250bbe476
30 files changed
+634 -67

No files matched your search

+1 -1
View File
@@ -8,7 +8,7 @@ import { DEFAULT_DIGITS, SEED_CURRENCIES } from "./types.ts";
* assert. What remains true is what a NEW dataset starts with.
*/
test("a new dataset starts with the currencies the app shipped with", () => {
expect(SEED_CURRENCIES.map((c) => c.code)).toEqual(["DKK", "USD", "EUR", "GBP"]);
expect(SEED_CURRENCIES.map((c) => c.code)).toEqual(["DKK", "USD", "EUR", "GBP", "JPY", "AUD", "CAD", "MXN"]);
});
test("sterling carries the pound sign, so \"10£\" parses", () => {
+7 -1
View File
@@ -33,9 +33,13 @@ export const SEED_CURRENCIES: readonly CurrencyDef[] = [
{ code: "USD", symbol: "$", name: "US dollar" },
{ code: "EUR", symbol: "\u20ac", name: "Euro" },
{ code: "GBP", symbol: "\u00a3", name: "British pound" },
{ code: "JPY", symbol: "\u00a5", name: "Japanese yen" },
{ code: "AUD", symbol: "AU$", name: "Australian dollar" },
{ code: "CAD", symbol: "CA$", name: "Canadian dollar" },
{ code: "MXN", symbol: "MX$", name: "Mexican peso" },
];
/** What a brand-new dataset rounds to. Right for every currency it ships with. */
/** Initial dataset-wide precision; applies to every currency, including JPY. */
export const DEFAULT_DIGITS = 2;
/** A float amount paired with its currency. See AGENTS.md for rounding rules. */
@@ -243,6 +247,8 @@ export interface Settings {
digits: number;
/** Optional: no migration needed, existing datasets leave it undefined. */
fxApiUrl?: string;
/** Allow linking new purchases to existing bills. Absent means false. */
allowExistingRecurringCost?: boolean;
}
export interface Dataset {
+44
View File
@@ -55,6 +55,50 @@ const data: Dataset = {
};
describe("monthView", () => {
test("actual spending before the fold start counts whether or not purchases have posts", () => {
const earlier: Dataset = { ...data,
settings: { ...data.settings, foldStartMonth: "2026-02" },
months: [{ id: "2026-01", income: { amount: 1000, currency: "DKK" }, ruleOverrides: {} }],
purchases: [spend("assigned", "food", 100, "2026-01"),
{ ...spend("loose", "food", 50, "2026-01"), splits: [] }],
};
expect(monthView(earlier, "2026-01").totalCharges).toBe(150);
expect(monthView(earlier, "2026-01").actualAvailable).toBe(850);
earlier.purchases[0]!.splits = [];
expect(monthView(earlier, "2026-01").actualAvailable).toBe(850);
});
test("actual available counts refunds, loose FX charges and only this month's plan slice", () => {
const actual: Dataset = {
...data,
fxRates: [{ currency: "USD", baseUnitsPerOne: 7, updatedAt: "2026-01-01", source: "manual" }],
purchases: [
spend("spent", "food", 500, "2026-01"),
spend("refund", "food", -100, "2026-01"),
{ ...spend("loose", "food", 10, "2026-01"), total: { amount: 10, currency: "USD" }, splits: [] },
{ ...spend("plan", "food", 300, "2026-01"), schedule: { slices: [
{ month: "2026-01", amount: { amount: 100, currency: "DKK" } },
{ month: "2026-02", amount: { amount: 200, currency: "DKK" } },
] } },
],
recurring: [{ id: "bill", name: "Unpaid", folderId: null, order: 0, archived: false,
amount: { amount: 2000, currency: "DKK" }, startDate: "2026-01",
recurrence: { kind: "everyNMonths", n: 1 }, anchoring: "calendar", splitMode: "percent", splits: [] }],
};
expect(monthView(actual, "2026-01").actualAvailable).toBe(9430);
});
test("actual available stays negative and follows dataset digits", () => {
const actual: Dataset = { ...data,
settings: { ...data.settings, digits: 3 },
months: [{ id: "2026-01", income: { amount: 1.234, currency: "DKK" }, ruleOverrides: {} }],
purchases: [spend("spent", "food", 2.345, "2026-01")],
};
expect(monthView(actual, "2026-01").actualAvailable).toBe(-1.111);
actual.settings.digits = 0;
expect(monthView(actual, "2026-01").actualAvailable).toBe(-1);
});
test("counts a loose purchase as spent without charging a post", () => {
const loose: Dataset = { ...data, purchases: [{ ...spend("loose", "food", 75, "2026-01"), splits: [] }] };
const view = monthView(loose, "2026-01");
+10 -2
View File
@@ -1,5 +1,5 @@
import { incomeFor, isOverridden, ruleForPost } from "./allocation.ts";
import { looseChargesForMonth } from "./charges.ts";
import { chargesForMonth, looseChargesForMonth } from "./charges.ts";
import { figuresFor, foldBalances, type Fold, type PostMonthFigures } from "./fold.ts";
import { roundMoney } from "./money.ts";
import { compareMonths, monthOf, monthRange, monthsOfYear } from "./months.ts";
@@ -19,6 +19,8 @@ export interface MonthViewModel {
totalAllocation: number;
totalCharges: number;
looseCharges: number;
/** This month's entered income minus actual charges; excludes unpaid bills. */
actualAvailable: number;
/** income - totalAllocation. Negative when allocations exceed income. */
unallocated: number;
/**
@@ -76,8 +78,13 @@ export function monthView(dataset: Dataset, monthId: MonthId): MonthViewModel {
rows.reduce((sum, r) => sum + r.figures.allocation, 0),
baseDigits,
);
// The fold deliberately has no figures before its start, but actual purchases
// still count there. Reuse charge calculations without inventing rollover.
const assignedCharges = compareMonths(monthId, dataset.settings.foldStartMonth) < 0
? [...chargesForMonth(dataset, monthId).values()]
: rows.map(row => row.figures.charges);
const totalCharges = roundMoney(
rows.reduce((sum, r) => sum + r.figures.charges, looseCharges),
assignedCharges.reduce((sum, amount) => sum + amount, looseCharges),
baseDigits,
);
@@ -87,6 +94,7 @@ export function monthView(dataset: Dataset, monthId: MonthId): MonthViewModel {
totalAllocation,
totalCharges,
looseCharges,
actualAvailable: roundMoney(income - totalCharges, baseDigits),
unallocated: roundMoney(income - totalAllocation, baseDigits),
overspentCount: rows.filter((r) => r.hasAllocationRule && r.figures.remaining < 0).length,
rows,
+12
View File
@@ -54,6 +54,18 @@ describe("round trip", () => {
});
describe("validation", () => {
test.each(["true", 1, null])("rejects a non-boolean existing-bill entry preference: %s", value => {
const data = populated();
(data.settings as any).allowExistingRecurringCost = value;
expect(() => parseDatasetJson(exportDatasetJson(data))).toThrow(ImportValidationError);
});
test.each([true, false, undefined])("round trips the existing-bill entry preference: %s", value => {
const data = populated();
data.settings.allowExistingRecurringCost = value;
expect(parseDatasetJson(exportDatasetJson(data)).settings.allowExistingRecurringCost).toBe(value);
});
test("accepts an unassigned recurring cost and its unassigned charge", () => {
const data = populated();
const cost = addRecurringCost(data, {
+4
View File
@@ -364,6 +364,10 @@ export function parseDatasetJson(text: string): Dataset {
requireArray(data, "fxRates");
requireCurrency(dataset.settings.baseCurrency, "settings.baseCurrency", defined);
if (dataset.settings.allowExistingRecurringCost !== undefined &&
typeof dataset.settings.allowExistingRecurringCost !== "boolean") {
throw new ImportValidationError("settings.allowExistingRecurringCost must be a boolean");
}
// One setting, every currency. Bad digits here would round every amount in
// the dataset to nonsense, so this is checked before anything is folded.
const digits = dataset.settings.digits;
+13 -1
View File
@@ -4,9 +4,17 @@ import { createSeedDataset } from "../domain/seed.ts";
import type { FxRate } from "../domain/types.ts";
import { MissingRateError, toBase } from "../domain/fx.ts";
test.each([
["JPY", 4.22], ["AUD", 462.02], ["CAD", 467.57], ["MXN", 36.31],
])("a fresh budget can convert a 100 %s purchase without fetching rates", (currency, amount) => {
const data = createSeedDataset("2026-10", FALLBACK_FX_RATES);
expect(data.currencies.some(def => def.code === currency)).toBe(true);
expect(toBase({ amount: 100, currency }, "DKK", data.fxRates, data.settings.digits)).toBe(amount);
});
describe("the committed fallback rates", () => {
test("cover every non-base currency and are honestly labelled manual", () => {
expect(FALLBACK_FX_RATES.map((r) => r.currency).sort()).toEqual(["EUR", "GBP", "USD"]);
expect(FALLBACK_FX_RATES.map((r) => r.currency).sort()).toEqual(["AUD", "CAD", "EUR", "GBP", "JPY", "MXN", "USD"]);
// "api" would claim a hardcoded number came from a rate service. Settings
// shows this field to the user.
expect(FALLBACK_FX_RATES.every((r) => r.source === "manual")).toBe(true);
@@ -19,6 +27,10 @@ describe("reading what the build baked in", () => {
{ currency: "USD", baseUnitsPerOne: 6.812345, updatedAt: "2026-09-02", source: "api" },
{ currency: "EUR", baseUnitsPerOne: 7.456789, updatedAt: "2026-09-02", source: "api" },
{ currency: "GBP", baseUnitsPerOne: 8.712345, updatedAt: "2026-09-02", source: "api" },
{ currency: "JPY", baseUnitsPerOne: 0.042226, updatedAt: "2026-10-02", source: "api" },
{ currency: "AUD", baseUnitsPerOne: 4.620218, updatedAt: "2026-10-02", source: "api" },
{ currency: "CAD", baseUnitsPerOne: 4.675738, updatedAt: "2026-10-02", source: "api" },
{ currency: "MXN", baseUnitsPerOne: 0.363135, updatedAt: "2026-10-02", source: "api" },
];
test("an unset variable falls back to the committed constants", () => {
+6
View File
@@ -44,6 +44,12 @@ export const FALLBACK_FX_RATES: readonly FxRate[] = Object.freeze([
updatedAt: "2026-09-02",
source: "manual" as const,
}),
// Frankfurter's 2026-10-02 DKK-base quote, inverted to DKK per unit.
// https://api.frankfurter.dev/v1/2026-10-02?base=DKK&symbols=JPY,AUD,CAD,MXN
Object.freeze({ currency: "JPY" as Currency, baseUnitsPerOne: 0.042226, updatedAt: "2026-10-02", source: "manual" as const }),
Object.freeze({ currency: "AUD" as Currency, baseUnitsPerOne: 4.620218, updatedAt: "2026-10-02", source: "manual" as const }),
Object.freeze({ currency: "CAD" as Currency, baseUnitsPerOne: 4.675738, updatedAt: "2026-10-02", source: "manual" as const }),
Object.freeze({ currency: "MXN" as Currency, baseUnitsPerOne: 0.363135, updatedAt: "2026-10-02", source: "manual" as const }),
]);
/**
+7 -1
View File
@@ -4,7 +4,13 @@ import { createSeedDataset } from "../domain/seed.ts";
import { currencyUsage } from "../domain/currencies.ts";
import type { Dataset } from "../domain/types.ts";
const draft = (): Dataset => createSeedDataset("2026-09");
// Use the original table so JPY remains a genuinely new/unknown test currency.
const draft = (): Dataset => createSeedDataset("2026-09", [], [
{ code: "DKK", symbol: "kr", name: "Danish krone" },
{ code: "USD", symbol: "$", name: "US dollar" },
{ code: "EUR", symbol: "€", name: "Euro" },
{ code: "GBP", symbol: "£", name: "British pound" },
]);
describe("adding a currency", () => {
test("appends it with the symbol and name given", () => {
+2 -2
View File
@@ -175,7 +175,7 @@ describe("reset", () => {
// Diverge from the seed in every direction a reset has to undo: an added
// currency, a renamed post, recorded income and a purchase.
await store.mutate((draft) => {
draft.currencies.push({ code: "JPY", symbol: "\u00a5", name: "Japanese yen" });
draft.currencies.push({ code: "CHF", name: "Swiss franc" });
draft.posts[0]!.name = "Renamed";
draft.posts.push({
id: "extra",
@@ -209,7 +209,7 @@ describe("reset", () => {
"Food",
"Events and Social",
]);
expect(after.currencies.map((c) => c.code)).toEqual(["DKK", "USD", "EUR", "GBP"]);
expect(after.currencies.map((c) => c.code)).toEqual(["DKK", "USD", "EUR", "GBP", "JPY", "AUD", "CAD", "MXN"]);
expect(after.fxRates).toEqual(rates);
expect(after.purchases).toEqual([]);
expect(after.months).toHaveLength(1);
+17 -1
View File
@@ -1,5 +1,21 @@
import { test, expect } from "bun:test";
import { commitHref, describeBuild, readBuildVersionEnv } from "./buildVersion.ts";
import * as metadata from "./buildVersion.ts";
test("the build date accessor reads injected metadata and tolerates browsers without process", () => {
const savedEnv = process.env.BUN_PUBLIC_BUILD_DATE;
const saved = (globalThis as { process?: unknown }).process;
try {
process.env.BUN_PUBLIC_BUILD_DATE = "2026-10-03";
expect(metadata.readBuildDateEnv()).toBe("2026-10-03");
delete (globalThis as { process?: unknown }).process;
expect(metadata.readBuildDateEnv()).toBeUndefined();
} finally {
(globalThis as { process?: unknown }).process = saved;
if (savedEnv === undefined) delete process.env.BUN_PUBLIC_BUILD_DATE;
else process.env.BUN_PUBLIC_BUILD_DATE = savedEnv;
}
});
test("a full commit SHA links to the exact source revision", () => {
expect(commitHref("61e7093c36f9a0111a5f1c8776aba4894c88ebc4"))
@@ -16,7 +32,7 @@ test("an absent stamp reads as development, not as a blank or a lie", () => {
expect(describeBuild("")).toBe("development");
});
test("build.ts's default timestamp becomes a date", () => {
test("a legacy timestamp version becomes a date", () => {
// String(Date.now()) for 2026-09-02T00:00:00Z.
expect(describeBuild("1788307200000")).toBe("2026-09-02");
});
+13 -3
View File
@@ -1,8 +1,7 @@
/**
* The build stamp shown in the colophon.
*
* `build.ts` already computes a version for the service worker; this exposes
* the same value to the app bundle. It is genuinely absent in development,
* `build.ts` injects the source ref and the UTC date separately. They are absent in development,
* which is the normal case rather than an error.
*/
@@ -20,6 +19,17 @@ export function readBuildVersionEnv(): string | undefined {
}
}
/** Build-time UTC date, never the date the owner happens to open the app. */
export function readBuildDateEnv(): string | undefined {
try {
return process.env.BUN_PUBLIC_BUILD_DATE;
} catch {
return undefined;
}
}
export const BUILD_DATE = readBuildDateEnv();
/** A millisecond epoch, used when no Git revision was supplied or available. */
const EPOCH_MS = /^\d{10,}$/;
@@ -34,7 +44,7 @@ const LONG_SHA = /^(?=.*[a-f])[0-9a-f]{12,}$/i;
* `BUILD_VERSION` may be anything the person building chose — a git sha, a tag,
* a release number — so an unrecognised value is printed as given rather than
* reformatted into something it is not. The one shape worth translating is
* build.ts's source-archive fallback, a `Date.now()` timestamp, because "1756809600000"
* a legacy timestamp supplied as the version, because "1756809600000"
* tells a reader nothing and "2026-09-02" tells them what they wanted to know.
*
* ISO rather than a locale format: this string appears in bug reports against
@@ -0,0 +1,54 @@
import { GlobalRegistrator } from "@happy-dom/global-registrator";
if (!GlobalRegistrator.isRegistered) GlobalRegistrator.register();
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
import { expect, test } from "bun:test";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { createSeedDataset } from "../../domain/seed.ts";
import { addRecurringCost } from "../../store/actions.ts";
import { store, today } from "../../store/index.ts";
import { ExpectedBand } from "./ExpectedBand.tsx";
test("Expected collapses, remembers the choice and restores a typed confirmation amount", () => {
localStorage.removeItem("budget2:expand-expected");
const monthId = today.slice(0, 7);
const data = createSeedDataset(monthId);
addRecurringCost(data, { name: "Phone", folderId: null, archived: false,
amount: { amount: 30, currency: "DKK" }, startDate: monthId,
recurrence: { kind: "everyNMonths", n: 1 }, anchoring: "calendar", splitMode: "percent", splits: [] });
const original = { get: store.get, subscribe: store.subscribe };
store.get = () => data;
store.subscribe = () => () => {};
const container = document.createElement("div");
document.body.append(container);
let root = createRoot(container);
const render = () => root.render(<ExpectedBand monthId={monthId} />);
try {
act(render);
const input = container.querySelector("input")!;
act(() => {
input.focus();
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, "35");
input.dispatchEvent(new InputEvent("input", { bubbles: true }));
input.dispatchEvent(new KeyboardEvent("keyup", { bubbles: true, key: "5" }));
});
const toggle = container.querySelector<HTMLButtonElement>('button[aria-expanded]');
expect(toggle !== null).toBe(true);
act(() => toggle!.click());
expect(toggle!.getAttribute("aria-expanded")).toBe("false");
expect(container.querySelector("input") === null).toBe(true);
expect(container.textContent).toContain("1 expected");
act(() => toggle!.click());
expect(container.querySelector("input")!.value).toBe("35");
act(() => toggle!.click());
act(() => root.unmount());
root = createRoot(container);
act(render);
expect(container.querySelector('button[aria-expanded]')!.getAttribute("aria-expanded")).toBe("false");
expect(data.purchases).toHaveLength(0);
} finally {
act(() => root.unmount()); container.remove();
Object.assign(store, original);
localStorage.removeItem("budget2:expand-expected");
}
});
+45 -29
View File
@@ -1,4 +1,5 @@
import { useMemo, useState } from "react";
import { useId, useMemo, useState } from "react";
import { ChevronDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useDataset } from "../hooks/useDataset.ts";
@@ -10,6 +11,7 @@ import { occurrencesByMonth, type Occurrence } from "../../domain/occurrences.ts
import { addMonths, compareMonths } from "../../domain/months.ts";
import { parseMoneyInput, type CurrencyOption, type ParsedMoney } from "../moneyInput.ts";
import { Section } from "./Section.tsx";
import { useDisplayPreference } from "../hooks/useDisplayPreference.ts";
import type { Currency, Dataset, IsoDate, MonthId } from "../../domain/types.ts";
/**
@@ -197,6 +199,8 @@ export function ExpectedBand({ monthId }: { monthId: MonthId }) {
const { mutate, error } = useMutate();
const fmt = useMoneyFormat();
const [amounts, setAmounts] = useState<Record<string, string>>({});
const [expanded, setExpanded] = useDisplayPreference("budget2:expand-expected", true);
const contentId = useId();
const { pending, overdue, comingUp } = useMemo(
() => expectedGroups(dataset, monthId, today),
@@ -309,7 +313,9 @@ export function ExpectedBand({ monthId }: { monthId: MonthId }) {
{fmt.money(occurrence.amount.amount, occurrence.amount.currency)}
</span>
<span className="text-xs text-budget-ink-muted">
Add a purchase and tag it to this cost to record a charge early.
{dataset.settings.allowExistingRecurringCost
? "Add a purchase and choose this cost to record a charge early."
: "Enable existing bills in Settings → Recurring costs to record an early charge from Add purchase."}
</span>
</div>
</li>
@@ -319,37 +325,47 @@ export function ExpectedBand({ monthId }: { monthId: MonthId }) {
return (
<Section
title="Expected"
hint="Committed but not yet spent. These do not touch a post's balance until you confirm one, which records it as an ordinary purchase you can edit or delete afterwards."
hint={expanded
? "Committed but not yet spent. These do not touch a post's balance until you confirm one, which records it as an ordinary purchase you can edit or delete afterwards."
: `${pending.length} expected · ${comingUp.length} coming up · ${overdue} overdue`}
action={<Button variant="ghost" size="sm" aria-expanded={expanded} aria-controls={contentId} onClick={() => setExpanded(!expanded)}>
{expanded ? "Collapse" : "Expand"}
<ChevronDown aria-hidden="true" className={expanded ? "rotate-180" : ""} />
</Button>}
>
{error && <p role="alert" className="mb-3 text-sm text-destructive">Could not confirm: {error}</p>}
{pending.length > 0 && (
<ul className="divide-y divide-budget-rule">
{pending.map((occurrence) => row(occurrence, recordedDateFor("pending", today)))}
</ul>
)}
<div id={contentId}>
{expanded && <>
{pending.length > 0 && (
<ul className="divide-y divide-budget-rule">
{pending.map((occurrence) => row(occurrence, recordedDateFor("pending", today)))}
</ul>
)}
{comingUp.length > 0 && (
<div className={pending.length > 0 ? "mt-4" : ""}>
<h3 className="mb-1 text-[0.6875rem] font-medium uppercase tracking-wider text-budget-ink-muted">
Coming up
</h3>
<ul className="divide-y divide-budget-rule">
{comingUp.map((occurrence) =>
occurrence.confirmable
? row(occurrence, recordedDateFor("comingUp", today))
: informationalRow(occurrence),
)}
</ul>
</div>
)}
{comingUp.length > 0 && (
<div className={pending.length > 0 ? "mt-4" : ""}>
<h3 className="mb-1 text-[0.6875rem] font-medium uppercase tracking-wider text-budget-ink-muted">
Coming up
</h3>
<ul className="divide-y divide-budget-rule">
{comingUp.map((occurrence) =>
occurrence.confirmable
? row(occurrence, recordedDateFor("comingUp", today))
: informationalRow(occurrence),
)}
</ul>
</div>
)}
{overdue > 0 && (
<p className="mt-3 text-xs leading-relaxed text-budget-ink-muted">
{overdue} earlier {overdue === 1 ? "occurrence has" : "occurrences have"} never
been confirmed. Until {overdue === 1 ? "it is" : "they are"}, the projected
balance stays lower than the real one by that amount.
</p>
)}
{overdue > 0 && (
<p className="mt-3 text-xs leading-relaxed text-budget-ink-muted">
{overdue} earlier {overdue === 1 ? "occurrence has" : "occurrences have"} never
been confirmed. Until {overdue === 1 ? "it is" : "they are"}, the projected
balance stays lower than the real one by that amount.
</p>
)}
</>}
</div>
</Section>
);
}
@@ -10,6 +10,36 @@ import { monthView } from "../../domain/views.ts";
import { store } from "../../store/index.ts";
import { MonthPieShowcase } from "./MonthPieShowcase.tsx";
test("hiding the breakdown stays remembered after remount and leaves its control reachable", () => {
localStorage.removeItem("budget2:show-month-breakdown");
const data = createSeedDataset("2026-09");
const original = { get: store.get, subscribe: store.subscribe };
store.get = () => data;
store.subscribe = () => () => {};
const container = document.createElement("div");
let root = createRoot(container);
const render = () => root.render(<MonthPieShowcase view={monthView(data, "2026-09")} baseCurrency="DKK" />);
try {
act(render);
const toggle = container.querySelector('input[type="checkbox"]') as HTMLInputElement;
expect(toggle).not.toBeNull();
expect(toggle.checked).toBe(true);
act(() => toggle.click());
expect(container.querySelector('svg[role="img"]')).toBeNull();
act(() => root.unmount());
root = createRoot(container);
act(render);
const remembered = container.querySelector('input[type="checkbox"]') as HTMLInputElement;
expect(remembered.checked).toBe(false);
act(() => remembered.click());
expect(container.querySelector('svg[role="img"]')).not.toBeNull();
} finally {
act(() => root.unmount());
Object.assign(store, original);
localStorage.removeItem("budget2:show-month-breakdown");
}
});
test("pie showcase switches between spending and allocations", () => {
const data = createSeedDataset("2026-09");
data.posts[0]!.rules = [{ from: "2026-09", rule: { kind: "fixed", amount: { amount: 250, currency: "DKK" } } }];
+12 -4
View File
@@ -4,6 +4,7 @@ import { monthPieData, type MonthPieMode } from "../monthPie.ts";
import { useMoneyFormat } from "../hooks/useMoneyFormat.ts";
import { Section } from "./Section.tsx";
import { Segmented } from "./Segmented.tsx";
import { useDisplayPreference } from "../hooks/useDisplayPreference.ts";
const COLORS = ["#0d6b60", "#cc8050", "#537ba8", "#9a6bb1", "#9b8b39", "#ca6574", "#4a9b93", "#816eaf"];
const OPTIONS = [
@@ -24,6 +25,7 @@ function wedge(start: number, end: number): string {
export function MonthPieShowcase({ view, baseCurrency }: { view: MonthViewModel; baseCurrency: string }) {
const [mode, setMode] = useState<MonthPieMode>("spending");
const [visible, setVisible] = useDisplayPreference("budget2:show-month-breakdown", true);
const fmt = useMoneyFormat();
const chart = monthPieData(view, mode);
let angle = 0;
@@ -32,10 +34,16 @@ export function MonthPieShowcase({ view, baseCurrency }: { view: MonthViewModel;
return (
<Section
title="Monthly breakdown"
hint={`Share of this month's ${label} by post${mode === "spending" ? ", including unassigned purchases" : ""}.`}
action={<Segmented value={mode} options={OPTIONS} onChange={setMode} label="Pie chart view" />}
hint={visible ? `Share of this month's ${label} by post${mode === "spending" ? ", including unassigned purchases" : ""}.` : undefined}
action={<>
<label className="flex min-h-8 cursor-pointer items-center gap-2 text-sm">
<input type="checkbox" checked={visible} onChange={event => setVisible(event.target.checked)} className="size-4 accent-primary" />
Show chart
</label>
{visible && <Segmented value={mode} options={OPTIONS} onChange={setMode} label="Pie chart view" />}
</>}
>
<div className="grid items-center gap-5 sm:grid-cols-[minmax(12rem,16rem)_1fr] sm:gap-8">
{visible && <div className="grid items-center gap-5 sm:grid-cols-[minmax(12rem,16rem)_1fr] sm:gap-8">
<div className="mx-auto w-full max-w-56">
<svg viewBox="0 0 200 200" role="img" aria-label={`${label} pie chart`} className="aspect-square w-full">
{chart.positiveTotal === 0 ? (
@@ -93,7 +101,7 @@ export function MonthPieShowcase({ view, baseCurrency }: { view: MonthViewModel;
</div>
)}
</div>
</div>
</div>}
</Section>
);
}
+111
View File
@@ -0,0 +1,111 @@
import { GlobalRegistrator } from "@happy-dom/global-registrator";
if (!GlobalRegistrator.isRegistered) GlobalRegistrator.register();
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
import { afterEach, expect, test } from "bun:test";
import { act } from "react";
import type { Root } from "react-dom/client";
import { createSeedDataset } from "../../domain/seed.ts";
import { addRecurringCost } from "../../store/actions.ts";
import { store } from "../../store/index.ts";
import type { Dataset, Purchase } from "../../domain/types.ts";
const { createRoot } = await import("react-dom/client");
const { PurchaseDialog } = await import("./PurchaseDialog.tsx");
const original = { get: store.get, subscribe: store.subscribe, mutate: store.mutate };
let root: Root;
let container: HTMLDivElement;
afterEach(() => {
if (root) act(() => root.unmount());
container?.remove();
Object.assign(store, original);
});
function mount(data: Dataset, purchase?: Purchase) {
store.get = () => data;
store.subscribe = () => () => {};
store.mutate = async change => { change(data); };
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
act(() => root.render(<PurchaseDialog monthId="2026-09" purchase={purchase} trigger={<button>Add</button>} />));
act(() => container.querySelector("button")!.click());
}
function type(id: string, value: string) {
const input = document.getElementById(id) as HTMLInputElement;
act(() => {
input.focus();
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, value);
input.dispatchEvent(new InputEvent("input", { bubbles: true, data: value, inputType: "insertText" }));
input.dispatchEvent(new KeyboardEvent("keyup", { bubbles: true, key: value.slice(-1) }));
});
}
function selection() { return document.getElementById("purchase-recurring") as HTMLSelectElement; }
function select(value: string) {
act(() => { selection().value = value; selection().dispatchEvent(new Event("change", { bubbles: true })); });
}
async function save() {
const button = [...document.querySelectorAll<HTMLButtonElement>('[role="dialog"] button')].find(b => b.textContent === "Save")!;
expect(button.disabled).toBe(false);
await act(async () => { button.click(); });
}
function withBill() {
const data = createSeedDataset("2026-09");
const bill = addRecurringCost(data, { name: "Phone", folderId: null, archived: false,
amount: { amount: 30, currency: "DKK" }, startDate: "2026-09",
recurrence: { kind: "everyNMonths", n: 1 }, anchoring: "calendar", splitMode: "percent", splits: [] });
return { data, bill };
}
test("saving and reopening a new purchase clears the visible total", async () => {
const data = createSeedDataset("2026-09");
mount(data);
type("purchase-description", "Groceries");
type("purchase-amount", "123.45");
await save();
expect(data.purchases[0]!.total.amount).toBe(123.45);
act(() => container.querySelector("button")!.click());
expect((document.getElementById("purchase-amount") as HTMLInputElement).value).toBe("");
expect((document.getElementById("purchase-description") as HTMLInputElement).value).toBe("");
});
test("new purchases offer creation instead of existing bills by default", async () => {
const { data, bill } = withBill();
mount(data);
expect(selection().value).toBe("none");
expect(selection().querySelector(`option[value="${bill.id}"]`) === null).toBe(true);
type("purchase-description", "Subscription");
type("purchase-amount", "50");
select("__new__");
await save();
expect(data.recurring).toHaveLength(2);
expect(data.recurring[1]!.name).toBe("Subscription");
expect(data.purchases[0]!.source?.recurringId).toBe(data.recurring[1]!.id);
});
test("enabling existing bills lets a new purchase settle the selected bill", async () => {
const { data, bill } = withBill();
data.settings.allowExistingRecurringCost = true;
mount(data);
select(bill.id);
type("purchase-description", "Phone payment");
type("purchase-amount", "30");
await save();
expect(data.recurring).toHaveLength(1);
expect(data.purchases[0]!.source).toEqual({ recurringId: bill.id, occurrenceDate: "2026-09" });
});
test("editing keeps its saved total and bill even when existing bills are hidden", async () => {
const { data, bill } = withBill();
const purchase: Purchase = { id: "p", description: "Phone payment", date: "2026-09",
total: { amount: 30, currency: "DKK" }, splitMode: "percent", splits: [], schedule: null,
source: { recurringId: bill.id, occurrenceDate: "2026-09" } };
data.purchases.push(purchase);
mount(data, purchase);
expect((document.getElementById("purchase-amount") as HTMLInputElement).value).toBe("30");
expect(selection().value).toBe(bill.id);
await save();
expect(data.purchases[0]!.source?.recurringId).toBe(bill.id);
});
+11 -7
View File
@@ -111,8 +111,12 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
selected && !offerable.some((cost) => cost.id === selected.id)
? [...offerable, selected]
: offerable;
const visibleCosts = dataset.settings.allowExistingRecurringCost
? selectableCosts
: selectableCosts.filter(cost => cost.id === purchase?.source?.recurringId);
function reset() {
setAmountText(purchase ? String(purchase.total.amount) : "");
setPlacement(purchase?.placement ? purchase.placement.folderId ?? "__unfiled" : "__ledger");
setDraft(
purchase ? fromPurchase(purchase) : emptyDraft(monthId, activePosts[0]?.id ?? ""),
@@ -199,7 +203,7 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
}}
>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogContent className="max-h-[calc(100dvh-2rem)] max-w-lg overflow-y-auto">
<DialogHeader>
<DialogTitle>
{purchase ? "Edit purchase" : bulkMode ? "Add many purchases" : "Add purchase"}
@@ -416,17 +420,17 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
}}
>
<option value="none">None</option>
{selectableCosts.map((cost) => (
{visibleCosts.map((cost) => (
<option key={cost.id} value={cost.id}>
{cost.name}
</option>
))}
<option value={NEW_COST}>+ New recurring cost…</option>
<option value={NEW_COST}>Create a recurring cost for this purchase…</option>
</NativeSelect>
<p className="text-xs text-budget-ink-muted">
Tag this purchase as a charge for one of your bills: for one
measured from the last charge, that also restarts its cycle
from today.
{dataset.settings.allowExistingRecurringCost
? "Create a new bill or record a charge for an existing one. A bill measured from its last charge restarts from this purchase's date."
: "Create a bill that repeats from this purchase. To choose an existing bill, enable that option in Settings → Recurring costs."}
</p>
{/* An anchored cost stays SELECTED when the date drops to
month-only (the selection is never taken away out from under
@@ -563,7 +567,7 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
)}
<p className="text-xs text-budget-ink-muted">
This purchase becomes its first charge. Anchored to the last
charge, its cycle restarts from today rather than from a fixed
charge, its cycle restarts from this purchase's date rather than from a fixed
grid.
</p>
</fieldset>
+22
View File
@@ -0,0 +1,22 @@
import { useCallback, useState } from "react";
/** Per-browser display choices, like theme; never part of an exported budget. */
export function useDisplayPreference(key: string, fallback: boolean) {
const [value, setValue] = useState(() => {
try {
const saved = localStorage.getItem(key);
return saved === "true" ? true : saved === "false" ? false : fallback;
} catch {
return fallback;
}
});
const update = useCallback((next: boolean) => {
setValue(next);
try {
localStorage.setItem(key, String(next));
} catch {
// Keep the control usable when the browser cannot remember the choice.
}
}, [key]);
return [value, update] as const;
}
+17
View File
@@ -1,5 +1,22 @@
import { test, expect, describe } from "bun:test";
import { parseMoneyInput, type CurrencyOption } from "./moneyInput.ts";
import { createSeedDataset } from "../domain/seed.ts";
test.each([
["30AU$", "AUD"], ["CA$30", "CAD"], ["30 MX$", "MXN"], ["¥30", "JPY"],
["30 AUD", "AUD"], ["30 CAD", "CAD"], ["30 MXN", "MXN"], ["30 JPY", "JPY"],
])("new budgets accept %s as %s", (input, code) => {
const data = createSeedDataset("2026-10");
expect(parseMoneyInput(input, data.currencies, "DKK")).toEqual({ amount: 30, currency: code });
});
test("longer dollar symbols are removed before the plain dollar sign", () => {
const options = [...OPTIONS, { code: "AUD", symbol: "AU$" }, { code: "CAD", symbol: "CA$" }, { code: "MXN", symbol: "MX$" }];
expect(parseMoneyInput("30AU$", options, "DKK")).toEqual({ amount: 30, currency: "AUD" });
expect(parseMoneyInput("CA$30", options, "DKK")).toEqual({ amount: 30, currency: "CAD" });
expect(parseMoneyInput("30 MX$", options, "DKK")).toEqual({ amount: 30, currency: "MXN" });
expect(parseMoneyInput("30$", options, "DKK")).toEqual({ amount: 30, currency: "USD" });
});
const OPTIONS: CurrencyOption[] = [
{ code: "DKK", symbol: "kr" },
+4 -2
View File
@@ -77,8 +77,11 @@ function detectCurrency(
function stripCurrency(text: string, options: readonly CurrencyOption[]): string {
let out = text;
for (const option of options) {
// Strip full symbols first: "$" would otherwise leave the "AU" in "AU$".
for (const option of [...options].sort((a, b) => (b.symbol?.length ?? 0) - (a.symbol?.length ?? 0))) {
if (option.symbol) out = out.split(option.symbol).join(" ");
}
for (const option of options) {
out = out.replace(new RegExp(option.code, "gi"), " ");
}
return out;
@@ -140,4 +143,3 @@ function parseAmount(text: string): number | null {
);
return Number.isFinite(value) ? sign * value : null;
}
+8 -1
View File
@@ -111,8 +111,15 @@ export function MonthRoute() {
</div>
<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>
<dl className="flex flex-wrap gap-x-10 gap-y-3">
<Stat label="Spent">{fmt.money(view.totalCharges, base)}</Stat>
<Stat
label="Actual available"
tone={view.actualAvailable < 0 ? "overspend" : "default"}
title="This month's income minus actual spending, including unassigned purchases."
>
{fmt.money(view.actualAvailable, base)}
</Stat>
{view.looseCharges !== 0 && (
<Stat label="Unassigned">{fmt.money(view.looseCharges, base)}</Stat>
)}
+3 -1
View File
@@ -1,6 +1,6 @@
import type { ReactNode } from "react";
import { useDataset } from "../../hooks/useDataset.ts";
import { BUILD_VERSION, COMMIT_HREF, SOURCE_REPOSITORY } from "../../buildVersion.ts";
import { BUILD_DATE, BUILD_VERSION, COMMIT_HREF, SOURCE_REPOSITORY } from "../../buildVersion.ts";
import { Section } from "../../components/Section.tsx";
const REPO = SOURCE_REPOSITORY.replace(/^https?:\/\//, "");
@@ -52,6 +52,8 @@ export function ColophonSection() {
</Entry>
<Entry label="Build">
{COMMIT_HREF ? <a href={COMMIT_HREF} target="_blank" rel="noopener noreferrer" className="font-money text-budget-accent underline underline-offset-2 hover:no-underline">{BUILD_VERSION}</a> : <span className="font-money">{BUILD_VERSION}</span>}
<span className="text-budget-ink-muted"> · </span>
{BUILD_DATE ? <time dateTime={BUILD_DATE} className="font-money">{BUILD_DATE}</time> : <span className="text-budget-ink-muted">no build date</span>}
</Entry>
</dl>
</Section>
@@ -10,6 +10,30 @@ import { store } from "../../../store/index.ts";
import { addRecurringCost } from "../../../store/actions.ts";
import { RecurringSection } from "./RecurringSection.tsx";
test("the existing-bill entry setting saves its checkbox value and reports write errors", async () => {
const data = createSeedDataset("2026-09");
const original = { get: store.get, subscribe: store.subscribe, mutate: store.mutate };
store.get = () => data;
store.subscribe = () => () => {};
store.mutate = async change => { change(data); };
const container = document.createElement("div");
const root = createRoot(container);
try {
act(() => root.render(<RecurringSection />));
const checkbox = container.querySelector<HTMLInputElement>('#allow-existing-recurring-cost');
expect(checkbox !== null).toBe(true);
expect(checkbox!.checked).toBe(false);
await act(async () => { checkbox!.click(); });
expect(data.settings.allowExistingRecurringCost).toBe(true);
store.mutate = async () => { throw new Error("Disk full"); };
await act(async () => { checkbox!.click(); });
expect(data.settings.allowExistingRecurringCost).toBe(true);
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Disk full");
} finally {
act(() => root.unmount()); Object.assign(store, original);
}
});
test("existing post destination requires a selection", async () => {
const data = createSeedDataset("2026-09");
data.posts[0]!.archived = true;
@@ -243,6 +243,24 @@ export function RecurringSection() {
title="Recurring costs"
hint="Bills that repeat. Each one projects forward as EXPECTED spending and is confirmed from the month view when the money actually moves — expected never touches a post's real balance. Anchoring decides where the next charge is measured from: the calendar, or the date the last one actually charged. Use the second for a bill that can charge early, like a phone plan that recharges when its data runs out."
>
<div className="mb-4 space-y-1">
<label htmlFor="allow-existing-recurring-cost" className="flex min-h-8 cursor-pointer items-center gap-2 text-sm">
<input
id="allow-existing-recurring-cost"
type="checkbox"
className="size-4 accent-primary"
checked={dataset.settings.allowExistingRecurringCost === true}
onChange={event => {
const enabled = event.target.checked;
void mutate(draft => { draft.settings.allowExistingRecurringCost = enabled; });
}}
/>
Allow choosing an existing recurring cost when adding a purchase
</label>
<p className="max-w-[75ch] text-xs leading-relaxed text-budget-ink-muted">
By default, a purchase can create a new recurring cost. Enable this to record a charge for an existing bill from Add purchase. Existing purchase links remain editable.
</p>
</div>
<p className="mb-3 text-xs"><a className="underline underline-offset-2" href="#budget-folders">Move and reorder bills in Budget folders</a></p>
{error && <p role="alert" className="mb-3 text-sm text-destructive">Could not save: {error}</p>}
{ordered.length === 0 ? (