diff --git a/build.ts b/build.ts index f97d107..2f17ed5 100644 --- a/build.ts +++ b/build.ts @@ -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 { - 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. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b684a29..fdadded 100755 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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: diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index b62759f..0ac19e4 100755 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -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. diff --git a/docs/TODO.md b/docs/TODO.md index e70cfec..fcd40f1 100755 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -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. diff --git a/docs/specs/2026-10-03-month-and-purchase-refinements.md b/docs/specs/2026-10-03-month-and-purchase-refinements.md new file mode 100644 index 0000000..f42af13 --- /dev/null +++ b/docs/specs/2026-10-03-month-and-purchase-refinements.md @@ -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. diff --git a/src/domain/types.test.ts b/src/domain/types.test.ts index 4587029..ae5cc5d 100644 --- a/src/domain/types.test.ts +++ b/src/domain/types.test.ts @@ -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", () => { diff --git a/src/domain/types.ts b/src/domain/types.ts index 9cd5810..d5c4c4a 100755 --- a/src/domain/types.ts +++ b/src/domain/types.ts @@ -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 { diff --git a/src/domain/views.test.ts b/src/domain/views.test.ts index a217640..317c75b 100644 --- a/src/domain/views.test.ts +++ b/src/domain/views.test.ts @@ -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"); diff --git a/src/domain/views.ts b/src/domain/views.ts index 61e01fe..7a12022 100644 --- a/src/domain/views.ts +++ b/src/domain/views.ts @@ -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, diff --git a/src/export/json.test.ts b/src/export/json.test.ts index 0c2fccf..f9e489b 100644 --- a/src/export/json.test.ts +++ b/src/export/json.test.ts @@ -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, { diff --git a/src/export/json.ts b/src/export/json.ts index db6f5af..89f74d7 100644 --- a/src/export/json.ts +++ b/src/export/json.ts @@ -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; diff --git a/src/store/bakedRates.test.ts b/src/store/bakedRates.test.ts index f2b0b26..45ab0b7 100644 --- a/src/store/bakedRates.test.ts +++ b/src/store/bakedRates.test.ts @@ -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", () => { diff --git a/src/store/bakedRates.ts b/src/store/bakedRates.ts index 957d417..d84c881 100644 --- a/src/store/bakedRates.ts +++ b/src/store/bakedRates.ts @@ -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 }), ]); /** diff --git a/src/store/currencyActions.test.ts b/src/store/currencyActions.test.ts index b43d2c6..3b394af 100644 --- a/src/store/currencyActions.test.ts +++ b/src/store/currencyActions.test.ts @@ -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", () => { diff --git a/src/store/snapshot.test.ts b/src/store/snapshot.test.ts index 4366587..f18390e 100644 --- a/src/store/snapshot.test.ts +++ b/src/store/snapshot.test.ts @@ -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); diff --git a/src/ui/buildVersion.test.ts b/src/ui/buildVersion.test.ts index b978504..853f397 100644 --- a/src/ui/buildVersion.test.ts +++ b/src/ui/buildVersion.test.ts @@ -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"); }); diff --git a/src/ui/buildVersion.ts b/src/ui/buildVersion.ts index 594d391..d115138 100644 --- a/src/ui/buildVersion.ts +++ b/src/ui/buildVersion.ts @@ -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 diff --git a/src/ui/components/ExpectedBand.interaction.test.tsx b/src/ui/components/ExpectedBand.interaction.test.tsx new file mode 100644 index 0000000..a25b3eb --- /dev/null +++ b/src/ui/components/ExpectedBand.interaction.test.tsx @@ -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(); + 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('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"); + } +}); diff --git a/src/ui/components/ExpectedBand.tsx b/src/ui/components/ExpectedBand.tsx index 5b73551..490498b 100644 --- a/src/ui/components/ExpectedBand.tsx +++ b/src/ui/components/ExpectedBand.tsx @@ -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>({}); + 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)} - 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."} @@ -319,37 +325,47 @@ export function ExpectedBand({ monthId }: { monthId: MonthId }) { return (
setExpanded(!expanded)}> + {expanded ? "Collapse" : "Expand"} +
); } diff --git a/src/ui/components/MonthPieShowcase.test.tsx b/src/ui/components/MonthPieShowcase.test.tsx index 33d9f59..48fff18 100644 --- a/src/ui/components/MonthPieShowcase.test.tsx +++ b/src/ui/components/MonthPieShowcase.test.tsx @@ -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(); + 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" } } }]; diff --git a/src/ui/components/MonthPieShowcase.tsx b/src/ui/components/MonthPieShowcase.tsx index 0207ef6..597a5c3 100644 --- a/src/ui/components/MonthPieShowcase.tsx +++ b/src/ui/components/MonthPieShowcase.tsx @@ -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("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 (
} + hint={visible ? `Share of this month's ${label} by post${mode === "spending" ? ", including unassigned purchases" : ""}.` : undefined} + action={<> + + {visible && } + } > -
+ {visible &&
{chart.positiveTotal === 0 ? ( @@ -93,7 +101,7 @@ export function MonthPieShowcase({ view, baseCurrency }: { view: MonthViewModel;
)}
-
+ }
); } diff --git a/src/ui/components/PurchaseDialog.test.tsx b/src/ui/components/PurchaseDialog.test.tsx new file mode 100644 index 0000000..2d92255 --- /dev/null +++ b/src/ui/components/PurchaseDialog.test.tsx @@ -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(Add} />)); + 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('[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); +}); diff --git a/src/ui/components/PurchaseDialog.tsx b/src/ui/components/PurchaseDialog.tsx index 1479690..60bc77c 100644 --- a/src/ui/components/PurchaseDialog.tsx +++ b/src/ui/components/PurchaseDialog.tsx @@ -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) { }} > {trigger} - + {purchase ? "Edit purchase" : bulkMode ? "Add many purchases" : "Add purchase"} @@ -416,17 +420,17 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) { }} > - {selectableCosts.map((cost) => ( + {visibleCosts.map((cost) => ( ))} - +

- 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."}

{/* 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) { )}

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.

diff --git a/src/ui/hooks/useDisplayPreference.ts b/src/ui/hooks/useDisplayPreference.ts new file mode 100644 index 0000000..1cfbb84 --- /dev/null +++ b/src/ui/hooks/useDisplayPreference.ts @@ -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; +} diff --git a/src/ui/moneyInput.test.ts b/src/ui/moneyInput.test.ts index 740dc14..5258eca 100644 --- a/src/ui/moneyInput.test.ts +++ b/src/ui/moneyInput.test.ts @@ -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" }, diff --git a/src/ui/moneyInput.ts b/src/ui/moneyInput.ts index d528bf9..6e6320c 100644 --- a/src/ui/moneyInput.ts +++ b/src/ui/moneyInput.ts @@ -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; } - diff --git a/src/ui/routes/MonthRoute.tsx b/src/ui/routes/MonthRoute.tsx index 78f9ee3..8890752 100644 --- a/src/ui/routes/MonthRoute.tsx +++ b/src/ui/routes/MonthRoute.tsx @@ -111,8 +111,15 @@ export function MonthRoute() {
-
+
{fmt.money(view.totalCharges, base)} + + {fmt.money(view.actualAvailable, base)} + {view.looseCharges !== 0 && ( {fmt.money(view.looseCharges, base)} )} diff --git a/src/ui/routes/settings/ColophonSection.tsx b/src/ui/routes/settings/ColophonSection.tsx index d144149..9e6ad27 100644 --- a/src/ui/routes/settings/ColophonSection.tsx +++ b/src/ui/routes/settings/ColophonSection.tsx @@ -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() { {COMMIT_HREF ? {BUILD_VERSION} : {BUILD_VERSION}} + · + {BUILD_DATE ? : no build date}
diff --git a/src/ui/routes/settings/RecurringSection.test.tsx b/src/ui/routes/settings/RecurringSection.test.tsx index 401c7da..d58d8a1 100644 --- a/src/ui/routes/settings/RecurringSection.test.tsx +++ b/src/ui/routes/settings/RecurringSection.test.tsx @@ -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()); + const checkbox = container.querySelector('#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; diff --git a/src/ui/routes/settings/RecurringSection.tsx b/src/ui/routes/settings/RecurringSection.tsx index c4c8b78..c0309a8 100644 --- a/src/ui/routes/settings/RecurringSection.tsx +++ b/src/ui/routes/settings/RecurringSection.tsx @@ -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." > +
+ +

+ 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. +

+

Move and reorder bills in Budget folders

{error &&

Could not save: {error}

} {ordered.length === 0 ? (