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
2026-10-03 16:40:00 +02:00
parent 7cccd7a41a
commit c250bbe476
30 changed files with 634 additions and 67 deletions
+10 -8
View File
@@ -6,7 +6,7 @@ import { buildManifest } from "./src/manifest.ts";
import { FONT_FILES } from "./src/fontCss.ts";
import { buildFxUrl, parseFxResponse, DEFAULT_FX_API_URL } from "./src/store/fxApi.ts";
import { FALLBACK_FX_RATES } from "./src/store/bakedRates.ts";
import type { Currency, FxRate } from "./src/domain/types.ts";
import { SEED_CURRENCIES, type Currency, type FxRate } from "./src/domain/types.ts";
const basePath = normalizeBase(process.env.BUN_PUBLIC_BASE_PATH);
const outdir = path.join(process.cwd(), "dist");
@@ -24,7 +24,10 @@ if (!process.env.BUILD_VERSION) {
// Source archives and Docker contexts may have no Git metadata.
}
}
const version = process.env.BUILD_VERSION || checkoutCommit || String(Date.now());
const builtAt = new Date();
const buildDate = builtAt.toISOString().slice(0, 10);
const buildRef = process.env.BUILD_VERSION || checkoutCommit;
const version = buildRef || String(builtAt.getTime());
let checkoutRepository: string | undefined;
if (!process.env.BUILD_REPOSITORY) {
try {
@@ -37,7 +40,7 @@ if (!process.env.BUILD_REPOSITORY) {
const sourceRepository = process.env.BUILD_REPOSITORY || checkoutRepository || "https://github.com/StereotypicalCat/budget2.0";
const FX_BASE: Currency = "DKK";
const FX_TARGETS: Currency[] = ["USD", "EUR", "GBP"];
const FX_TARGETS: Currency[] = SEED_CURRENCIES.map(currency => currency.code).filter(code => code !== FX_BASE);
const FX_TIMEOUT_MS = 8_000;
/**
@@ -55,7 +58,6 @@ const FX_TIMEOUT_MS = 8_000;
* FX_API_URL overrides the endpoint.
*/
async function bakeFxRates(): Promise<readonly FxRate[]> {
const buildDate = new Date().toISOString().slice(0, 10);
const describe = (rates: readonly FxRate[]) =>
rates.map((r) => `${r.currency} ${r.baseUnitsPerOne}`).join(", ");
@@ -128,10 +130,10 @@ const result = await Bun.build({
define: {
"process.env.NODE_ENV": JSON.stringify("production"),
"process.env.BUN_PUBLIC_BASE_PATH": JSON.stringify(basePath),
// The same `version` the service worker gets, so the colophon can name the
// build a bug report is about. Only defined here, never in dev, which is
// why it is read through a try/catch accessor.
"process.env.BUN_PUBLIC_BUILD_VERSION": JSON.stringify(version),
// Source identity and build date are independent. Archives have no Git ref;
// the service worker still gets a unique version to identify their assets.
"process.env.BUN_PUBLIC_BUILD_VERSION": JSON.stringify(buildRef || "source archive"),
"process.env.BUN_PUBLIC_BUILD_DATE": JSON.stringify(buildDate),
"process.env.BUN_PUBLIC_SOURCE_REPOSITORY": JSON.stringify(sourceRepository),
// Double-encoded on purpose: the inlined literal must be a JSON *string*
// for parseBakedRates to parse at runtime.
+26
View File
@@ -62,6 +62,12 @@ aggregate envelope figures over `foldBalances` / `figuresFor`. Overall Spent
also includes unassigned purchase slices, which have no envelope to fold. A
closing balance is read from `byMonth[11].remaining`, never re-summed.
The month header's Actual available is entered income minus all actual Spent
for that month. It includes refunds, unassigned spending and finance-plan slices,
but no allocation, carry or unpaid expectations. Before the rollover start,
the month view reads the existing charge calculation directly because the fold
has deliberately produced no envelope figures yet.
A recurring cost adds a SECOND balance beside it, on its own carry:
```
@@ -157,6 +163,11 @@ still works.
## Money
The default currency table seeds new datasets only. JPY, AUD, CAD and MXN join
DKK, USD, EUR and GBP; existing currency tables are not rewritten. The build
derives its FX targets from the seed table and carries complete committed
fallback rates. These rates never replace one an owner has cleared.
A float, deliberately, with two rules that make it safe:
1. **Round at every boundary** via `roundMoney` — after any division, after any
@@ -187,6 +198,11 @@ which also says what re-adding a per-currency override would take.
## Splits and finance plans compose
Purchase entry defaults to creating a new recurring cost when the owner chooses
to make a purchase recur. Selecting an existing bill is enabled explicitly in
Settings by the optional `allowExistingRecurringCost` boolean (absent is false),
validated on JSON import. Existing purchase associations remain editable.
A purchase can be split across posts *and* spread over months at once. The trick
is in `charges.ts`: first compute how the **whole** purchase divides across posts
(`splitPartsOfTotal`), then distribute each month's slice proportionally *to
@@ -211,6 +227,16 @@ explicit zero rule, including a zero monthly override, does have an overspend
status when its balance is negative. The views use the presence of a rule rather
than infer it from the numeric allocation, since both cases fold to zero.
## Display preferences and build identity
Monthly chart visibility and Expected's expanded state persist per browser,
alongside the theme preference. They never enter an exported dataset or change
the month calculations. Storage errors leave the display controls usable.
Colophon receives a source ref and UTC build date separately from the build.
The date describes the artifact, not the browser's current day. A source archive
has no Git ref but still receives a unique service-worker cache version.
## Things the app deliberately does NOT validate
Adding a "missing" guard here is a regression, not a fix:
+6 -2
View File
@@ -71,6 +71,9 @@ Confirmed and in place:
- A purchase may have no post. It remains visible in its month and counts in
Spent, while leaving envelope balances unchanged.
- Per-month income, entered directly, varying month to month.
- The month header shows actual available: that month's entered income minus
actual spending, including unassigned purchases and refunds. Monthly breakdown
can be hidden and Expected collapsed; these choices persist in this browser.
- Multi-currency (DKK/USD/EUR) against a user-maintained exchange-rate table,
with an optional on-demand fetch that fails soft and is never required.
- JSON export/import and ODS (spreadsheet) export, both generated in the
@@ -92,8 +95,9 @@ Confirmed and in place:
whichever charge was most recent — a phone plan that recharges early when
its data runs out is the case this exists for. Each due date on the
calendar model is a projected occurrence the owner confirms into a real
purchase; a recurring cost can also be tagged onto a purchase, or created
from one, while it is being entered, rather than only from Settings. The
purchase; a recurring cost can be created from a purchase while it is being
entered. Choosing an existing bill in Add purchase is enabled explicitly in
Settings; existing purchase associations remain editable. The
month view shows what is still expected alongside a second, projected
balance per post, so a known bill is visible before it is paid without
touching the reconciled figure a bank statement is checked against.
+1 -1
View File
@@ -10,7 +10,7 @@ task moved to `AGENTS.md`.
**Current state:** the anchored model is merged with budget folders and
placement. The month pie showcase, browser-stored session backups, and
unassigned recurring costs are implemented. 881 tests pass across 64 files;
unassigned recurring costs are implemented. 911 tests pass across 66 files;
`bunx tsc --noEmit` is clean, and `SKIP_FX_FETCH=1 bun run build` succeeds
using the committed seed rates.
@@ -0,0 +1,96 @@
# Month and purchase refinements
The owner requested this round on 2026-10-03, with the work queued in TODO
before implementation. The follow-up added a visible Total-field reset bug.
## Actual available
The month header shows entered income minus actual spending beside Spent.
This includes unassigned purchases, refunds, foreign-currency charges and the
selected month's finance-plan slices. Unpaid recurring occurrences do not
reduce it. Allocation and envelope carry do not enter this calculation.
Negative values remain visible, and arithmetic and display use dataset digits.
Purchases before the envelope fold's start still count in Spent and Actual
available. The month view uses the existing charge calculation for those
months; the rollover fold continues to begin at its configured start.
## Display choices
Monthly breakdown has a Show chart checkbox. Its header and checkbox remain
visible when the chart and detail list are hidden. Expected has an Expand /
Collapse button and shows counts for expected, coming-up and overdue entries
when closed. Closing it preserves any typed confirmation amount.
Both choices default to visible/expanded and persist across months and reloads
in browser localStorage. This follows the theme decision: display preferences
belong to this browser, not to the exported budget. Blocked localStorage must
not stop the app or the controls from working. Neither choice changes money,
occurrences or confirmation behavior.
## Purchase entry
New purchases begin with Recurring cost set to None. Their recurring option
creates a new bill from this purchase, rather than selecting an existing bill.
Creating a bill and its first charge remains one atomic mutation. Calendar
slots and last-charge date requirements retain their existing rules.
Settings → Recurring costs includes a checkbox to allow selecting existing
bills in Add purchase. The optional dataset boolean is
`settings.allowExistingRecurringCost`; absent means false, so existing budgets
adopt the new default without a migration. JSON import accepts only booleans
when this field is present. The choice travels with a JSON export.
Editing a linked purchase continues to show its saved association even with
the setting off, and choosing None can unlink it. Ordinary purchases do not
automatically become recurring bills. This resolves the default-selection
ambiguity in favor of an explicit recurring action.
The Total field has its own text state to preserve partly typed money inputs.
Resetting the dialog now resets that text alongside the numeric draft: new
purchases start blank and existing purchases start with their saved amount.
The expanded new-bill form exceeded a phone viewport in a browser check. The
purchase dialog now caps its height to the viewport and scrolls its contents,
keeping both the first fields and Save reachable.
## Default currencies
New datasets include Japanese yen (JPY, ¥), Australian dollars (AUD, AU$),
Canadian dollars (CAD, CA$) and Mexican pesos (MXN, MX$). Existing tables are
not migrated: the request specified defaults, and editing an established
table is the owner's action. Every currency still uses dataset-wide precision.
The build's FX targets derive from the seed table. All four have committed
fallback rates as well as live build-time fetching. The committed rates were
obtained from Frankfurter's DKK-base quote dated 2026-10-02 and inverted to
DKK per unit, preserving six decimal places. Like the previous constants,
these rows are marked manual and seed only new datasets.
Source: https://api.frankfurter.dev/v1/2026-10-02?base=DKK&symbols=JPY,AUD,CAD,MXN
The amount parser strips longer symbols before the plain dollar sign, so
AU$, CA$ and MX$ work without leaving stray letters in the amount. An explicit
currency code still disambiguates a symbol collision.
## Build stamp
Colophon shows the source ref and a separate build-time UTC date. An exact
commit keeps its source link; an uncommitted checkout retains the dirty suffix.
Source archives say source archive rather than inventing a Git ref, while
the service worker still receives a unique cache version. Development says
development and no build date. Browser accessors tolerate an absent process
global, following the existing Bun environment-inlining rule.
## Verification
Regression coverage exercises the actual money calculation, purchase save /
reopen, new recurring creation, enabled existing-bill selection, linked editing,
display persistence, collapse/expand preserving a typed amount, Settings write
errors, JSON preference validation, symbol parsing and new seed conversion.
Chrome checks verify the month controls, saving and reopening a purchase,
persisting the Settings choice to IndexedDB, creating a new bill and its first
charge together, keeping mobile Save reachable, and production build metadata.
Desktop and mobile captures cover light and dark themes. Screenshots establish
layout and console behavior; visual feel still requires the owner.
The complete suite passes 911 tests across 66 files, TypeScript is clean, and
production builds succeed at both root and subpath base URLs with seed rates.
+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 ? (