feat: refine month totals and recurring purchase entry
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 }),
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
});
|
||||
@@ -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" } } }];
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user