Merge: TODO sweep, design foundation, service worker fixes, user-defined currencies
Sections 2 and 4 of TODO.md complete, section 3 (redesign) one sub-project in, plus two features requested mid-flight. Schema moves to version 3. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
151cd530f5
97 files changed
+4837
-681
No files matched your search
@@ -18,5 +18,8 @@ Then, depending on what you are doing:
|
||||
Two rules worth knowing before your first edit:
|
||||
|
||||
1. **Bun only.** `bun test`, `bun run`, `bunx`. Never npm, jest, vitest or vite.
|
||||
2. **There is no browser here.** Anything visual, offline, or focus-related
|
||||
cannot be verified in this environment. Say so rather than claiming it works.
|
||||
2. **There IS a browser here** — `bun scripts/screenshot.ts <url> <out.png>`
|
||||
drives headless Chrome and hands you a PNG to look at. Read CLAUDE.md's
|
||||
"Verification limits" first: the container's fonts lie, and offline, the
|
||||
install prompt, the `.ods` in a spreadsheet, and how anything *feels* still
|
||||
need a human. Say so rather than claiming it works.
|
||||
+25
-6
@@ -88,6 +88,15 @@ A float, deliberately, with two rules that make it safe:
|
||||
**`FxRate.baseUnitsPerOne` is never rounded.** Rates are stored at six decimals
|
||||
and are not money; quantizing one to cents is wrong in kind.
|
||||
|
||||
**Decimal places are data, not a constant.** `Dataset.currencies` records each
|
||||
currency's code, name, symbol and digits, because the owner can define their
|
||||
own. `roundMoney` therefore takes a digit count and every call site resolves it
|
||||
with `digitsFor` — required rather than defaulted, so a missed site is a
|
||||
compile error instead of something that works for every 2-decimal currency and
|
||||
breaks on the first zero-decimal one. `src/domain/currencyDigits.test.ts` drives
|
||||
a zero-decimal currency through the whole stack, which is the only test that can
|
||||
tell a real implementation from one that assumes 2.
|
||||
|
||||
## Splits and finance plans compose
|
||||
|
||||
A purchase can be split across posts *and* spread over months at once. The trick
|
||||
@@ -136,12 +145,22 @@ Adding a "missing" guard here is a regression, not a fix:
|
||||
Most logic is pure and tested directly with `bun test`. The domain carries the
|
||||
bulk of the coverage; the UI is thin by design.
|
||||
|
||||
**There is no browser in the agent environment.** Nothing has ever verified
|
||||
visual appearance, real offline behaviour, the install or update prompts,
|
||||
keyboard focus traversal, or the `.ods` opening in a spreadsheet. Where a check
|
||||
could not be run, the honest substitute was used and recorded — for example the
|
||||
`.ods` is validated structurally by unzipping it and asserting `mimetype` is
|
||||
first and stored, every XML part parses, and numeric cells carry `office:value`.
|
||||
**A browser IS available**, via `scripts/screenshot.ts` — headless Chrome
|
||||
driven over CDP, because Chrome's own `--screenshot` fires on the load event
|
||||
and captures an empty page before this app's async IndexedDB read resolves.
|
||||
That script is how the month view, the carry meter and the rule editors were
|
||||
first seen by anyone. It reports console errors too, which is the point: a page
|
||||
that renders blank because of a thrown error looks identical to an empty one.
|
||||
|
||||
Its limits are real. The container's fontconfig maps every generic family to a
|
||||
monospace face, so screenshots need a `FONTCONFIG_FILE` override to be
|
||||
representative; Chrome starts with an empty profile each run; and no screenshot
|
||||
can judge how a design feels. Real offline behaviour, the install and update
|
||||
prompts, and the `.ods` opening in a spreadsheet remain unverifiable here.
|
||||
Where a check could not be run, the honest substitute was used and recorded —
|
||||
the `.ods` is validated structurally by unzipping it and asserting `mimetype`
|
||||
is first and stored, every XML part parses, and numeric cells carry
|
||||
`office:value`.
|
||||
|
||||
`src/ui/eventCapture.test.ts` is a *source-level* guard rather than a
|
||||
behavioural one, because the bug it prevents depends on real browser render
|
||||
|
||||
@@ -111,13 +111,29 @@ For more information, read the Bun API docs in `node_modules/bun-types/docs/**.m
|
||||
without an explicit argument. It must be testable with no DOM.
|
||||
- Money is a float. Always round with `roundMoney` from `src/domain/money.ts`
|
||||
after any division, after any FX conversion, and before persisting.
|
||||
- **`roundMoney(amount, digits)` takes a DIGIT COUNT, not a currency code.**
|
||||
Decimal places live in `Dataset.currencies`, because the owner defines
|
||||
currencies; resolve them with `digitsFor(dataset.currencies, code)`. The
|
||||
argument is required on purpose — a default of 2 is right for every currency
|
||||
the app shipped with, so a missed call site would pass every test and be
|
||||
silently wrong for the first zero-decimal currency added.
|
||||
- When splitting an amount, never round the last part independently. Compute it
|
||||
as `total - sum(others)` so the parts sum exactly to the whole.
|
||||
- `MonthId` is the string `"YYYY-MM"`. Stored data never contains `Date` objects.
|
||||
- **`Currency` is an open string code; `Dataset.currencies` is the authority.**
|
||||
There is no global list of valid currencies any more. A code is IDENTITY — it
|
||||
keys the FX table and every stored `Money` — so codes are normalised
|
||||
uppercase, are never editable, and a currency still referenced cannot be
|
||||
removed (`currencyUsage` names what refers to it). `parseDatasetJson`
|
||||
validates the table and every `Money` against it; that boundary check is
|
||||
what replaced the compile-time safety of the old closed union.
|
||||
- The envelope rollover fold has exactly one implementation, in
|
||||
`src/domain/fold.ts`. Year and summary views aggregate over it — they never
|
||||
reimplement the math.
|
||||
- Over-budget is always allowed. Never add validation that blocks it.
|
||||
- An amount field parses its own currency: "30$" is thirty dollars. Use
|
||||
`parseMoneyInput` (`src/ui/moneyInput.ts`) and keep the input `type="text"` —
|
||||
a number input discards the "$" before the parser ever sees it.
|
||||
- Base path comes from `BUN_PUBLIC_BASE_PATH`; never hardcode a subpath, and
|
||||
read it through a `try/catch` accessor — see the gotcha below.
|
||||
- A post's allocation is a dated series, `Post.rules: RuleVersion[]`, resolved by
|
||||
@@ -148,8 +164,16 @@ choice.
|
||||
unset, the bare reference reaches the browser and throws before the app boots.
|
||||
A `typeof process` guard does not work — it survives inlining and discards the
|
||||
inlined value, silently breaking subpath deploys.
|
||||
- **Never define a bare `--accent` CSS variable.** shadcn owns it; overriding it
|
||||
drops hover contrast below WCAG AA app-wide. Project tokens are `--budget-*`.
|
||||
- **Never define a bare `--accent` CSS variable.** shadcn owns it as a hover
|
||||
BACKGROUND paired with `--accent-foreground`; setting one without the other
|
||||
drops hover contrast below WCAG AA app-wide. Redefine shadcn tokens only in
|
||||
pairs — `src/cssPairs.test.ts` enforces it — and put brand colour on
|
||||
`--primary`, never `--accent`. Project tokens are `--budget-*`.
|
||||
- **A `var(--token)` naming a token `src/index.css` does not define paints
|
||||
NOTHING, silently.** An undefined custom property invalidates the whole
|
||||
declaration: no warning, no throw, no fallback. Renaming `--rule` this way
|
||||
killed the carry meter and no test noticed. `src/ui/cssTokens.test.ts` guards
|
||||
it.
|
||||
- **A React error boundary does not catch event-handler throws.** Domain
|
||||
functions that throw must be guarded at the call site when called from one.
|
||||
- **Do not "fix" a domain throw by making it return a fallback.** Silently wrong
|
||||
@@ -167,9 +191,25 @@ Adding a "missing" guard here is a regression:
|
||||
|
||||
## Verification limits
|
||||
|
||||
There is no browser in this environment. Visual appearance, offline behaviour,
|
||||
install and update prompts, keyboard focus traversal, and opening the generated
|
||||
`.ods` in a spreadsheet **cannot be verified here**. Do not claim you checked
|
||||
them. Substitute a real check where one exists — structural validation of the
|
||||
`.ods`, curl against the dev server, pure-function tests — and state plainly
|
||||
what still needs a human.
|
||||
**There IS a browser here.** `google-chrome` is on PATH, and
|
||||
`bun scripts/screenshot.ts <url> <out.png>` drives it over CDP: it waits past
|
||||
the async IndexedDB read (which plain `--screenshot` does not), can seed data
|
||||
with `--eval-file`, click with `--click`, and reports console errors and
|
||||
uncaught exceptions. Read the PNG it writes — you can see the app. Use it
|
||||
before claiming anything about layout.
|
||||
|
||||
Two things to know before trusting a screenshot:
|
||||
|
||||
- **This container's fontconfig resolves every generic family to Fira Code, a
|
||||
monospace font**, so the whole UI renders monospaced in a way no real user
|
||||
sees. Point `FONTCONFIG_FILE` at an override that maps the generics to
|
||||
installed sans/mono faces, or you will "fix" a font bug that does not exist.
|
||||
- Chrome launches with a fresh profile each run, so IndexedDB starts empty and
|
||||
the app re-seeds. Pass `--eval-file=<seed.js> --reload` for populated data.
|
||||
|
||||
Still **not** verifiable here, so do not claim it: real offline behaviour, the
|
||||
install and update prompts, opening the generated `.ods` in a spreadsheet, and
|
||||
anything about how the design *feels*. A screenshot proves a layout exists and
|
||||
is not broken; it does not prove it is good. Substitute a real check where one
|
||||
exists — structural validation of the `.ods`, curl against the dev server,
|
||||
pure-function tests — and state plainly what still needs a human.
|
||||
@@ -3,10 +3,15 @@
|
||||
Work queued for whoever picks this up next, human or agent. Ordered. Each item
|
||||
says enough to act on without re-deriving the reasoning.
|
||||
|
||||
**Current state:** branch `feature/versioned-rules`, 9 commits ahead of `main`,
|
||||
not merged, not pushed. 288 tests passing, `bunx tsc --noEmit` clean,
|
||||
`bun run build` succeeds. Versioned allocation rules are COMPLETE, all six
|
||||
tasks. Neither new editing surface has been seen in a browser — see section 5.
|
||||
**Current state:** merged to `main`. 419 tests passing, `bunx tsc --noEmit`
|
||||
clean, `bun run build` succeeds on both the live-fetch and offline paths.
|
||||
Sections 2 and 4 are DONE. Section 3 (redesign) is 1 of 4 sub-projects in.
|
||||
Section 6 (currencies) is DONE. Every screen has now actually been looked at —
|
||||
see section 5.
|
||||
|
||||
**Schema is at version 3.** v1 -> v2 made allocation rules a dated series;
|
||||
v2 -> v3 moved currencies into the dataset. Both migrations are
|
||||
behaviour-preserving by construction and tested as such.
|
||||
|
||||
---
|
||||
|
||||
@@ -35,9 +40,13 @@ fold). Worth a read before reviewing the diff.
|
||||
Not yet done for this feature: nobody has independently reviewed tasks 3-6, and
|
||||
no browser has seen the two new editing surfaces.
|
||||
|
||||
## 2. Baked FX rates — designed and approved, not built
|
||||
## 2. ~~Baked FX rates~~ — DONE (`b4e1933`)
|
||||
|
||||
So a fresh install can convert a EUR purchase on day one instead of erroring.
|
||||
Built as designed, all three build paths verified (live fetch, unreachable
|
||||
endpoint, `SKIP_FX_FETCH=1`), and confirmed in the real UI: Settings shows
|
||||
USD 6.449532 / EUR 7.474959 as `manual · 2026-09-01` on a fresh dataset.
|
||||
The reasoning below is kept because it is the reasoning, not a plan.
|
||||
|
||||
- `build.ts` fetches DKK/USD/EUR at build time and embeds them via `define`;
|
||||
on any network failure it falls back to **committed constants** rather than
|
||||
@@ -52,50 +61,108 @@ So a fresh install can convert a EUR purchase on day one instead of erroring.
|
||||
**argument**; the env read lives in a new `src/store/bakedRates.ts`, in the
|
||||
store layer, using the `try/catch` accessor pattern (see CLAUDE.md gotchas).
|
||||
|
||||
## 3. Visual redesign
|
||||
## 3. Visual redesign — sub-project 1 of 4 DONE
|
||||
|
||||
The owner's words: *"right now it looks a bit boring, simple and ugly. We want
|
||||
sleek, modern, and nice to use."* Sequencing was their explicit choice: finish
|
||||
all six rules tasks first, then redesign everything.
|
||||
sleek, modern, and nice to use."* Then, on scoping: both themes with a toggle,
|
||||
a full rethink of every screen, purchases compact and grouped by date, ship a
|
||||
webfont — and *"override everything you want for this step."*
|
||||
|
||||
`PRODUCT.md` exists and holds product truth. There is no `DESIGN.md`; the
|
||||
incumbent visual world is the `--budget-*` tokens in `src/index.css` plus the
|
||||
design contract summarised in ARCHITECTURE.md. A redesign should treat that as
|
||||
evidence and anti-reference, not as something to preserve.
|
||||
**Spec:** `docs/superpowers/specs/2026-09-02-design-foundation-design.md`
|
||||
|
||||
## 4. Smaller items, in no particular order
|
||||
**The diagnosis, which should shape the rest:** a design system already existed
|
||||
and was never wired up. `src/index.css` defined the paper/ink/teal palette with
|
||||
a contract document behind it, while the app painted shadcn's default white and
|
||||
near-black. The tokens reached the screen in two places only. Doing the
|
||||
foundation last is how that happened; do not repeat it.
|
||||
|
||||
- **`confirmImport` has no `.catch`** (`src/ui/routes/settings/DataSection.tsx`).
|
||||
A failed import write is an unhandled rejection with no error shown. One line.
|
||||
- **PNG icons 192/512 are not shipped**, so Chrome's install prompt is
|
||||
incomplete. Binary assets cannot be generated in the agent environment; a
|
||||
human needs to add them.
|
||||
- **`round2` in `src/store/actions.ts` hardcodes 2 decimals** rather than
|
||||
reading `CURRENCY_DIGITS`. Correct for DKK/USD/EUR; silently wrong the day a
|
||||
non-2dp currency is added. Same latent issue in `sliceTotal`
|
||||
(`src/domain/plans.ts`).
|
||||
- **`EMPTY_FIGURES` (`src/domain/fold.ts`) is exported non-frozen** and returned
|
||||
by shared reference from `figuresFor`. No consumer mutates it today;
|
||||
`Object.freeze` is a one-line hardening.
|
||||
- **`buildWorkbook` calls `monthView` per (post, month)**, making a full ODS
|
||||
export O(posts x months^2). Fine at personal-budget scale.
|
||||
- **The dev-only `src/manifest.webmanifest` placeholder hardcodes `scope: "/"`.**
|
||||
Harmless in production, misleading if you dev-serve under a subpath.
|
||||
| # | Sub-project | State |
|
||||
|---|---|---|
|
||||
| 1 | Design foundation — two-theme tokens, theme toggle, self-hosted Inter + JetBrains Mono, one Section/Stat card, table furniture | DONE (`bb3c661`) |
|
||||
| 2 | Month view | date-grouped purchases DONE (`a159189`); income/totals block and the carry meter's presentation still open |
|
||||
| 3 | Year, Summary, Post detail | on the new foundation but not rethought. The 12-month matrix only fits by scrolling — decide whether compact number formatting replaces that |
|
||||
| 4 | Dialogs and fast entry | untouched. Native `<select>` elements are still browser-default, and `up`/`down`/`archive` are bare text buttons |
|
||||
|
||||
## 5. Needs a human with a browser
|
||||
**Watch for:** the brand teal is `--primary`, never `--accent` — shadcn's
|
||||
`--accent` is a hover *background* whose paired foreground must move with it.
|
||||
`src/cssPairs.test.ts` enforces the pairing, and `src/ui/cssTokens.test.ts`
|
||||
fails if a component names a token `index.css` does not define. That second
|
||||
guard exists because renaming `--rule` silently made the carry meter paint
|
||||
nothing: an undefined custom property inside `linear-gradient()` invalidates
|
||||
the whole declaration without warning.
|
||||
|
||||
Nothing in the agent environment can run a browser, so these have never been
|
||||
seen by anyone:
|
||||
## 4. ~~Smaller items~~ — ALL DONE
|
||||
|
||||
- the **carry meter** under each post row in the month view — the signature
|
||||
design element, verified only as a CSS gradient expression;
|
||||
- the **rule-history editor** in Settings and the **"change from here"** control
|
||||
in the month view. Their arithmetic is verified through the real fold, and
|
||||
both badge predicates were driven as pure functions, but nobody has seen the
|
||||
expand/collapse, the layout, the disabled states, or the focus order;
|
||||
- **offline behaviour**, the **install prompt**, and the **update prompt**;
|
||||
All six, `b9be882` / `71e8fc9` / `f50c65b`. Three were worse than recorded:
|
||||
|
||||
- `confirmImport` now guards its write, and a source-level check covers the
|
||||
whole UI (`b9be882`).
|
||||
- The PNG icons **were** generatable here — Chrome is on PATH and rasterizes
|
||||
the SVG exactly. `scripts/make-icons.ts` produces them; they are committed,
|
||||
precached, and pixel-verified against the source (`f50c65b`).
|
||||
- Split values now round by what they MEAN — money in "fixed" mode, a
|
||||
percentage in "percent" mode. Narrower than the note claimed: `round2` was
|
||||
always right for `Rule.percent`, which is not money (`b9be882`).
|
||||
- `EMPTY_FIGURES` is frozen (`b9be882`).
|
||||
- The ODS export was **not** "fine at personal-budget scale": 30 posts over 10
|
||||
years took 7983 ms synchronously on the main thread. Now 135 ms, with
|
||||
byte-identical output (`71e8fc9`).
|
||||
- The dev manifest did not merely hardcode `scope: "/"` — it was never served
|
||||
at all; the catch-all answered with the HTML shell. One definition now feeds
|
||||
the build and the dev server (`b9be882`).
|
||||
|
||||
## 6. ~~User-defined currencies, and amounts that carry their currency~~ — DONE
|
||||
|
||||
Both `cdece63`. `Currency` is an open string code and `Dataset.currencies` is
|
||||
the authority on validity, symbols and decimal places; the purchase total
|
||||
field reads "30$" as USD 30.
|
||||
|
||||
**The part worth knowing before touching money again:** `roundMoney(amount,
|
||||
digits)` takes a digit count, and its ~29 call sites each resolve digits from
|
||||
the dataset with `digitsFor`. The argument is required deliberately — every
|
||||
currency the app shipped with uses two decimals, so a default of 2 would leave
|
||||
a missed call site passing every test and silently wrong for the first
|
||||
zero-decimal currency added. `src/domain/currencyDigits.test.ts` is the only
|
||||
test that can tell a real implementation from one that assumes 2: it drives a
|
||||
JPY-style currency through rounding, both FX directions, both split modes,
|
||||
plan division, the fold and the month view.
|
||||
|
||||
Left undone, deliberately:
|
||||
|
||||
- **The income field and the split editor's fixed-amount fields do not parse a
|
||||
currency yet** — only the purchase total does. Same helper
|
||||
(`parseMoneyInput`), same three-line pattern; the income field is arguably
|
||||
wrong to make multi-currency at all, since a month has one income.
|
||||
- **No currency picker convenience.** Adding one means typing the code, name,
|
||||
symbol and decimals by hand. A bundled ISO 4217 list would be nicer, and is
|
||||
pure data.
|
||||
- **Symbol collisions resolve to the first currency defined with that symbol.**
|
||||
Fine for one "$" currency; if the owner adds two, typing the code
|
||||
disambiguates and nothing warns them. Tested, documented, not solved.
|
||||
|
||||
## 5. Still needs a human
|
||||
|
||||
`scripts/screenshot.ts` drives headless Chrome, so the list here is much
|
||||
shorter than it was. **Seen and confirmed working:** the carry meter (it
|
||||
renders, and shows red where a post is overspent), the rule-history editor in
|
||||
Settings, the "change from here" control, the baked FX rates arriving in a
|
||||
fresh dataset, and every route at desktop and mobile widths.
|
||||
|
||||
Genuinely outstanding:
|
||||
|
||||
- **How it looks and feels.** A screenshot proves a layout is not broken. It
|
||||
cannot tell you the design is good, and the container's fonts are not the
|
||||
owner's fonts.
|
||||
- **Offline behaviour** and the **install prompt**. The service worker itself
|
||||
is no longer unverified: it demonstrably installs, activates, claims the
|
||||
page, precaches all 11 assets, and applies an update cleanly (`e6da85e`) —
|
||||
before that fix it had never installed once, in any environment, because
|
||||
`cache.addAll` rejects on a duplicate request. What is still unverified is
|
||||
a real offline load with the network actually cut, and Chrome's install
|
||||
prompt appearing.
|
||||
- the generated **`.ods` opening in a real spreadsheet** (validated
|
||||
structurally: mimetype first and stored, well-formed XML, numeric cells);
|
||||
structurally: mimetype first and stored, well-formed XML, numeric cells).
|
||||
- **keyboard flow in fast entry** — Tab traversal, Backspace-to-remove, and
|
||||
focus placement after a row auto-appends;
|
||||
focus placement after a row auto-appends. Drivable over CDP in principle;
|
||||
not yet done.
|
||||
- **GitHub Actions** has never been executed.
|
||||
@@ -2,11 +2,72 @@ import tailwind from "bun-plugin-tailwind";
|
||||
import { rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { normalizeBase } from "./src/ui/basePath.ts";
|
||||
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";
|
||||
|
||||
const basePath = normalizeBase(process.env.BUN_PUBLIC_BASE_PATH);
|
||||
const outdir = path.join(process.cwd(), "dist");
|
||||
const version = process.env.BUILD_VERSION ?? String(Date.now());
|
||||
|
||||
const FX_BASE: Currency = "DKK";
|
||||
const FX_TARGETS: Currency[] = ["USD", "EUR"];
|
||||
const FX_TIMEOUT_MS = 8_000;
|
||||
|
||||
/**
|
||||
* Exchange rates embedded at build time so a fresh install can convert a EUR
|
||||
* purchase on day one instead of erroring. They seed a new dataset only — see
|
||||
* src/store/bakedRates.ts for why they must never backstop a missing rate.
|
||||
*
|
||||
* A network failure must NOT fail the build: it falls back to the committed
|
||||
* constants, which are labelled "manual" so Settings never claims a hardcoded
|
||||
* number came from a rate service. Fetched rates carry the build date rather
|
||||
* than the date the browser happens to run, which is why parseFxResponse
|
||||
* takes an explicit `updatedAt`.
|
||||
*
|
||||
* SKIP_FX_FETCH=1 forces the constants, for offline or reproducible builds.
|
||||
* 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(", ");
|
||||
|
||||
if (process.env.SKIP_FX_FETCH === "1") {
|
||||
console.log(`Baking the committed FX constants (SKIP_FX_FETCH=1): ${describe(FALLBACK_FX_RATES)}`);
|
||||
return FALLBACK_FX_RATES;
|
||||
}
|
||||
|
||||
const template = process.env.FX_API_URL ?? DEFAULT_FX_API_URL;
|
||||
try {
|
||||
const response = await fetch(buildFxUrl(template, FX_BASE, FX_TARGETS), {
|
||||
signal: AbortSignal.timeout(FX_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) throw new Error(`rate service returned ${response.status}`);
|
||||
|
||||
const rates = parseFxResponse(await response.json(), FX_BASE, FX_TARGETS, buildDate);
|
||||
// A partial response would leave one currency unconvertible on a fresh
|
||||
// install, which is the whole problem this solves. Prefer the complete
|
||||
// constants over an incomplete live answer.
|
||||
const covered = new Set(rates.map((r) => r.currency));
|
||||
const missing = FX_TARGETS.filter((c) => !covered.has(c));
|
||||
if (missing.length > 0) throw new Error(`response omitted ${missing.join(", ")}`);
|
||||
|
||||
console.log(`Baked live FX rates for ${buildDate}: ${describe(rates)}`);
|
||||
return rates;
|
||||
} catch (cause) {
|
||||
const message = cause instanceof Error ? cause.message : String(cause);
|
||||
console.warn(
|
||||
`Could not fetch FX rates (${message}); baking the committed constants instead: ${describe(FALLBACK_FX_RATES)}`,
|
||||
);
|
||||
return FALLBACK_FX_RATES;
|
||||
}
|
||||
}
|
||||
|
||||
const bakedFxRates = await bakeFxRates();
|
||||
|
||||
await rm(outdir, { recursive: true, force: true });
|
||||
|
||||
const result = await Bun.build({
|
||||
@@ -43,6 +104,9 @@ const result = await Bun.build({
|
||||
define: {
|
||||
"process.env.NODE_ENV": JSON.stringify("production"),
|
||||
"process.env.BUN_PUBLIC_BASE_PATH": JSON.stringify(basePath),
|
||||
// Double-encoded on purpose: the inlined literal must be a JSON *string*
|
||||
// for parseBakedRates to parse at runtime.
|
||||
"process.env.BUN_PUBLIC_BAKED_FX_RATES": JSON.stringify(JSON.stringify(bakedFxRates)),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -51,11 +115,28 @@ if (!result.success) {
|
||||
throw new Error("Build failed");
|
||||
}
|
||||
|
||||
/**
|
||||
* Written to dist below rather than emitted by the bundler, so they are absent
|
||||
* from `result.outputs` and have to be listed for the precache by name.
|
||||
* Without them an installed app cannot render its own icon offline, and the
|
||||
* manifest 404s.
|
||||
*/
|
||||
const STATIC_ASSETS = [
|
||||
"manifest.webmanifest",
|
||||
"icon.svg",
|
||||
"icon-192.png",
|
||||
"icon-512.png",
|
||||
...FONT_FILES.map((file) => `fonts/${file}`),
|
||||
];
|
||||
|
||||
// The precache list comes from the build's own outputs, so hashed filenames can
|
||||
// never drift out of sync with the service worker.
|
||||
const precache = result.outputs
|
||||
.filter((output) => !output.path.endsWith(".map"))
|
||||
.map((output) => basePath + path.relative(outdir, output.path).replaceAll(path.sep, "/"));
|
||||
const precache = [
|
||||
...result.outputs
|
||||
.filter((output) => !output.path.endsWith(".map"))
|
||||
.map((output) => basePath + path.relative(outdir, output.path).replaceAll(path.sep, "/")),
|
||||
...STATIC_ASSETS.map((file) => basePath + file),
|
||||
];
|
||||
|
||||
const swResult = await Bun.build({
|
||||
entrypoints: ["src/sw.ts"],
|
||||
@@ -75,25 +156,17 @@ if (!swResult.success) {
|
||||
throw new Error("Service worker build failed");
|
||||
}
|
||||
|
||||
export function buildManifest(base: string): string {
|
||||
return JSON.stringify(
|
||||
{
|
||||
name: "Budget 2.0",
|
||||
short_name: "Budget",
|
||||
start_url: base,
|
||||
scope: base,
|
||||
display: "standalone",
|
||||
background_color: "#0f172a",
|
||||
theme_color: "#0f172a",
|
||||
icons: [{ src: `${base}icon.svg`, sizes: "any", type: "image/svg+xml" }],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
await Bun.write(path.join(outdir, "manifest.webmanifest"), buildManifest(basePath));
|
||||
await Bun.write(path.join(outdir, "icon.svg"), Bun.file("src/icon.svg"));
|
||||
for (const size of [192, 512]) {
|
||||
await Bun.write(
|
||||
path.join(outdir, `icon-${size}.png`),
|
||||
Bun.file(`src/icon-${size}.png`),
|
||||
);
|
||||
}
|
||||
for (const file of FONT_FILES) {
|
||||
await Bun.write(path.join(outdir, "fonts", file), Bun.file(`src/fonts/${file}`));
|
||||
}
|
||||
|
||||
// This is an SPA: every route serves the same shell, so the *document* URL
|
||||
// can be several segments deep (e.g. /budget2.0/month/2026-09). Relative
|
||||
@@ -105,9 +178,14 @@ await Bun.write(path.join(outdir, "icon.svg"), Bun.file("src/icon.svg"));
|
||||
// rather than hardcoded in src/index.html so the dev server (which always
|
||||
// serves from "/") keeps working unchanged.
|
||||
let indexHtml = await Bun.file(path.join(outdir, "index.html")).text();
|
||||
indexHtml = indexHtml
|
||||
.replace('href="manifest.webmanifest"', `href="${basePath}manifest.webmanifest"`)
|
||||
.replace('href="icon.svg"', `href="${basePath}icon.svg"`);
|
||||
// src/index.html references these relatively, because the dev server's HTML
|
||||
// bundler resolves every <link href> from disk and cannot be told to skip an
|
||||
// absolute one. Relative hrefs would 404 on any route deeper than the base
|
||||
// (/budget2.0/month/2026-09 resolves them against /budget2.0/month/), so
|
||||
// rewrite them absolute here. At "/" each replacement is a no-op.
|
||||
for (const asset of ["manifest.webmanifest", "icon.svg"]) {
|
||||
indexHtml = indexHtml.replace(`href="${asset}"`, `href="${basePath}${asset}"`);
|
||||
}
|
||||
await Bun.write(path.join(outdir, "index.html"), indexHtml);
|
||||
|
||||
// GitHub Pages has no rewrite rules, so a deep link on a cold load lands on
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
# Design foundation
|
||||
|
||||
Sub-project 1 of the visual redesign. Owner's brief: *"right now it looks a bit
|
||||
boring, simple and ugly. We want sleek, modern, and nice to use."* Owner's
|
||||
answers to the four scoping questions: both themes with a toggle, a full
|
||||
rethink of every screen, purchases compact and grouped by date, and a
|
||||
self-hosted webfont. Owner then delegated the specifics: *"Override everything
|
||||
you want for this step. This needs to look good."*
|
||||
|
||||
## The diagnosis that shaped everything else
|
||||
|
||||
A design system already existed and was never wired up. `src/index.css`
|
||||
defined `--paper` (annotated "cool statement-paper ground, NOT cream"),
|
||||
`--ink`, a muted teal, and surplus/overspend colours, with a design-contract
|
||||
document behind them. The app painted itself with shadcn's defaults —
|
||||
`bg-background text-foreground`, i.e. white and near-black. Those tokens
|
||||
reached the screen in exactly two places: the carry meter, and the sign
|
||||
colours on figures.
|
||||
|
||||
So "boring, simple and ugly" was not a missing design. It was a design that was
|
||||
specified and then bypassed. That is why the foundation comes first in the
|
||||
decomposition, and why it re-themes shadcn rather than styling screens: doing
|
||||
it screen by screen is how the bypass happened.
|
||||
|
||||
## Decomposition
|
||||
|
||||
The redesign is four sub-projects, each its own spec → plan → implementation:
|
||||
|
||||
1. **Design foundation** (this document) — tokens, themes, type, shared furniture.
|
||||
2. **Month view** — income and totals, post rows and carry meter, purchases grouped by date.
|
||||
3. **Year, Summary, Post detail** — the reporting screens, including whether the 12-month matrix stays a scrolling table.
|
||||
4. **Dialogs and fast entry** — purchase dialog, split editor, plan editor, bulk entry.
|
||||
|
||||
A single spec covering all four would have meant dozens of layout decisions
|
||||
made on the owner's behalf under one approval.
|
||||
|
||||
## Token architecture
|
||||
|
||||
Two layers, deliberately separate.
|
||||
|
||||
**Layer 1, `--budget-*`**: the app's own vocabulary — paper, raised, ink,
|
||||
ink-muted, rule, rule-strong, accent, accent-ink, tint — plus `--surplus` and
|
||||
`--overspend`, which shadcn has no concept of. Defined once per theme. The only
|
||||
colours app components may name.
|
||||
|
||||
**Layer 2, shadcn**: its entire variable set points at layer 1. Every primitive
|
||||
(dialog, select, badge, button) inherits both themes for free.
|
||||
|
||||
### The brand teal is `--primary`, never `--accent`
|
||||
|
||||
CLAUDE.md's hardest-won rule is "never define a bare `--accent`", paid for with
|
||||
an app-wide contrast bug. The mechanism matters: shadcn uses `--accent` as a
|
||||
hover **background** with `--accent-foreground` as the text on it. Setting one
|
||||
without the other repaints the background and leaves the text behind.
|
||||
|
||||
Two conclusions follow, and both are enforced rather than remembered:
|
||||
|
||||
- Every shadcn pair is redefined **together**, in both themes.
|
||||
`src/cssPairs.test.ts` fails the build if either half of a pair appears
|
||||
without the other, in any block.
|
||||
- The saturated teal belongs on `--primary`. Putting a brand colour on
|
||||
`--accent` — which is what "override `--accent`" invites — turns every hover
|
||||
into a coloured slab with dark text on it. `--accent` stays a tint of the
|
||||
surface.
|
||||
|
||||
### Contrast was computed, not chosen
|
||||
|
||||
Weakest text pair: 5.50:1 (muted on paper, light) against a 4.5:1 requirement.
|
||||
Field borders were iterated until they cleared the 3:1 non-text requirement on
|
||||
**both** backgrounds they appear against — the first two candidates measured
|
||||
2.17 and 2.55 and were rejected. The final palette was then read back out of
|
||||
the live browser in both themes, so the check is on what actually rendered
|
||||
rather than on the arithmetic.
|
||||
|
||||
## Typography
|
||||
|
||||
Inter for text, JetBrains Mono for figures, both variable, both self-hosted,
|
||||
latin + latin-ext only. SIL OFL 1.1; licences committed beside the files.
|
||||
Self-hosted rather than CDN-linked because PRODUCT.md's position is that the
|
||||
data never leaves the browser and the app needs no third party — a font request
|
||||
to another origin on every cold load quietly contradicts that.
|
||||
|
||||
`.font-money` carries tabular figures and a slashed zero, so columns align on
|
||||
the decimal and `0` cannot be misread as `O`.
|
||||
|
||||
### Rejected: @font-face in the bundled stylesheet
|
||||
|
||||
The obvious home. Bun's CSS bundler resolves every `url()` from disk and inlines
|
||||
woff2 as base64 — **relative or absolute**, and `loader: { ".woff2": "file" }`
|
||||
does not change it. Measured cost: `index.css` went from 70 KB to 303 KB of
|
||||
render-blocking bytes, and the `unicode-range` subsetting became pointless,
|
||||
since a subset embedded in the stylesheet downloads whether or not one
|
||||
character needs it.
|
||||
|
||||
Also rejected: a `<link rel="stylesheet" href="/fonts.css">`. It works in a
|
||||
production build (`external: ["*fonts.css"]`), but the dev server's HTML
|
||||
bundler resolves every `<link href>` from disk and has no `external` option, so
|
||||
dev would serve a placeholder and screenshots would lie about typography.
|
||||
Making the manifest and icon hrefs root-absolute for the same reason 500'd the
|
||||
dev server outright.
|
||||
|
||||
**Chosen**: `src/fontCss.ts` is the one definition, injected as a `<style>` at
|
||||
boot by `frontend.tsx`. Identical in dev and in a subpath build, no placeholder
|
||||
file, no href rewriting. The cost is that faces are requested after the JS
|
||||
parses rather than from the HTML; `font-display: swap` keeps text readable
|
||||
throughout, and the service worker precaches all four files.
|
||||
|
||||
## Theme switching
|
||||
|
||||
Cycles system → light → dark. Three states because "follow the system" is a
|
||||
real answer and, once you have chosen otherwise, there must be a way back to
|
||||
it. While set to "system" the app keeps listening, so it follows the OS
|
||||
switching at sunset without a reload.
|
||||
|
||||
Stored in `localStorage`, not in the dataset: the dataset is the owner's budget,
|
||||
which is exported, imported and schema-versioned, and importing a backup taken
|
||||
on a desktop must not flip a phone to dark. Every storage access is wrapped —
|
||||
`localStorage` **throws** on access in some configurations rather than
|
||||
returning null, and a display preference must never be why the app fails to
|
||||
boot.
|
||||
|
||||
A tiny inline script in `index.html` applies the saved theme before first
|
||||
paint, so a dark-theme user never sees a flash of the light palette. It cannot
|
||||
import the module (it has to run before any module loads), so it repeats the
|
||||
key and the fallback logic; `src/ui/theme.test.ts` asserts the two agree.
|
||||
|
||||
## The regression this produced, and the guard it earned
|
||||
|
||||
Renaming `--rule` to `--budget-rule` silently killed the carry meter — the
|
||||
month view's signature element. An undefined custom property inside
|
||||
`linear-gradient()` does not warn, does not throw, and does not fall back: the
|
||||
whole declaration is invalid, so the element paints nothing. It looked like a
|
||||
missing feature rather than a bug, and no test noticed. Pixel-sampling a
|
||||
screenshot found it.
|
||||
|
||||
`src/ui/cssTokens.test.ts` now fails if any TypeScript file references a custom
|
||||
property `src/index.css` does not define. This is the second guard of its kind
|
||||
in the repo, after `eventCapture.test.ts`, and for the same reason: the failure
|
||||
is invisible in review and invisible in a green suite.
|
||||
|
||||
## Verification, and its limits
|
||||
|
||||
`scripts/screenshot.ts` drives headless Chrome over CDP, which is how any of
|
||||
this was seen at all. Both themes were rendered with real data on every route
|
||||
at desktop and mobile widths; the resolved tokens were read out of the browser;
|
||||
the carry meter's presence was confirmed by pixel sampling rather than by eye.
|
||||
|
||||
Not verified, and not verifiable here: how it feels, real offline behaviour,
|
||||
the install prompt, and typography on the owner's actual platform — this
|
||||
container has no Inter installed for the fallback stack and its fontconfig maps
|
||||
every generic family to a monospace face, so screenshots need a
|
||||
`FONTCONFIG_FILE` override to be representative at all.
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Downloads the self-hosted webfonts into src/fonts/.
|
||||
*
|
||||
* bun scripts/fetch-fonts.ts
|
||||
*
|
||||
* Self-hosted, not linked from a CDN, because PRODUCT.md's position is that the
|
||||
* app works offline and needs no third party: a font request to another origin
|
||||
* on every cold load contradicts that, and would leak the fact that the app was
|
||||
* opened. The files are COMMITTED so neither the build nor a fresh checkout
|
||||
* needs network.
|
||||
*
|
||||
* Only the `latin` and `latin-ext` subsets are taken. Danish needs æ, ø and å
|
||||
* (U+00E6, U+00F8, U+00E5), all inside `latin`; `latin-ext` covers the rest of
|
||||
* European diacritics for post names. Cyrillic, Greek and Vietnamese are
|
||||
* dropped, which is most of the weight.
|
||||
*
|
||||
* Both faces are SIL Open Font License 1.1, which permits self-hosting as long
|
||||
* as the licence travels with them — hence src/fonts/OFL.txt.
|
||||
*/
|
||||
import { mkdir } from "node:fs/promises";
|
||||
|
||||
const UA =
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36";
|
||||
|
||||
const FAMILIES = [
|
||||
{ css: "Inter:wght@100..900", slug: "inter" },
|
||||
{ css: "JetBrains+Mono:wght@400..700", slug: "jetbrains-mono" },
|
||||
];
|
||||
const WANTED = new Set(["latin", "latin-ext"]);
|
||||
|
||||
await mkdir("src/fonts", { recursive: true });
|
||||
|
||||
for (const family of FAMILIES) {
|
||||
const url = `https://fonts.googleapis.com/css2?family=${family.css}&display=swap`;
|
||||
const css = await (await fetch(url, { headers: { "user-agent": UA } })).text();
|
||||
|
||||
// Google labels each @font-face block with a /* subset */ comment above it.
|
||||
const blocks = css.split("/*").slice(1);
|
||||
let taken = 0;
|
||||
|
||||
for (const block of blocks) {
|
||||
const subset = block.slice(0, block.indexOf("*/")).trim();
|
||||
if (!WANTED.has(subset)) continue;
|
||||
|
||||
const src = /url\((https:\/\/[^)]+\.woff2)\)/.exec(block)?.[1];
|
||||
const range = /unicode-range:\s*([^;]+);/.exec(block)?.[1]?.trim();
|
||||
if (!src || !range) throw new Error(`${family.slug}/${subset}: no src or unicode-range`);
|
||||
|
||||
const response = await fetch(src, { headers: { "user-agent": UA } });
|
||||
if (!response.ok) throw new Error(`${src} returned ${response.status}`);
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
|
||||
const file = `src/fonts/${family.slug}-${subset}.woff2`;
|
||||
await Bun.write(file, bytes);
|
||||
console.log(`${file} ${bytes.length} bytes`);
|
||||
console.log(` unicode-range: ${range}`);
|
||||
taken++;
|
||||
}
|
||||
|
||||
if (taken !== WANTED.size) {
|
||||
throw new Error(`${family.slug}: took ${taken} subsets, expected ${WANTED.size}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Rasterizes src/icon.svg into the PNG sizes Chrome's install prompt wants.
|
||||
*
|
||||
* bun scripts/make-icons.ts
|
||||
*
|
||||
* The output is COMMITTED rather than produced by build.ts, because this needs
|
||||
* a Chrome binary on PATH and the build must not. Re-run it whenever
|
||||
* src/icon.svg changes; src/icons.test.ts checks the committed files are real
|
||||
* images of the right dimensions, but it cannot tell you they are STALE.
|
||||
*
|
||||
* Chrome is the rasterizer because it is the only one present in this
|
||||
* environment — no ImageMagick, no rsvg-convert, no cairosvg. It renders the
|
||||
* SVG in a page sized exactly to the target, on a transparent backdrop, so the
|
||||
* icon's own rounded corners stay transparent.
|
||||
*/
|
||||
import { rm, mkdtemp } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const SIZES = [192, 512];
|
||||
const CHROME = process.env.CHROME ?? "google-chrome";
|
||||
/** Chrome has been seen not to exit on its own here, so never wait forever. */
|
||||
const TIMEOUT_MS = 60_000;
|
||||
|
||||
const work = await mkdtemp(path.join(tmpdir(), "budget-icons-"));
|
||||
try {
|
||||
await Bun.write(path.join(work, "icon.svg"), Bun.file("src/icon.svg"));
|
||||
await Bun.write(
|
||||
path.join(work, "wrap.html"),
|
||||
`<!doctype html><meta charset="utf-8">
|
||||
<style>html,body{margin:0;padding:0;background:transparent}
|
||||
img{display:block;width:100vw;height:100vh}</style>
|
||||
<img src="icon.svg">`,
|
||||
);
|
||||
|
||||
for (const size of SIZES) {
|
||||
const out = path.join(work, `icon-${size}.png`);
|
||||
const chrome = Bun.spawn(
|
||||
[
|
||||
CHROME,
|
||||
"--headless",
|
||||
"--disable-gpu",
|
||||
"--no-sandbox",
|
||||
"--no-first-run",
|
||||
"--hide-scrollbars",
|
||||
"--force-device-scale-factor=1",
|
||||
"--default-background-color=00000000",
|
||||
`--screenshot=${out}`,
|
||||
`--window-size=${size},${size}`,
|
||||
`file://${path.join(work, "wrap.html")}`,
|
||||
],
|
||||
{ stdout: "ignore", stderr: "ignore" },
|
||||
);
|
||||
const timer = setTimeout(() => chrome.kill(), TIMEOUT_MS);
|
||||
await chrome.exited;
|
||||
clearTimeout(timer);
|
||||
if (!(await Bun.file(out).exists())) {
|
||||
throw new Error(`${CHROME} wrote no screenshot for ${size}px`);
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(await Bun.file(out).arrayBuffer());
|
||||
const view = new DataView(bytes.buffer);
|
||||
const width = view.getUint32(16);
|
||||
const height = view.getUint32(20);
|
||||
if (width !== size || height !== size) {
|
||||
throw new Error(`Expected ${size}x${size}, got ${width}x${height}`);
|
||||
}
|
||||
|
||||
const dest = `src/icon-${size}.png`;
|
||||
await Bun.write(dest, bytes);
|
||||
console.log(`${dest} ${width}x${height} ${bytes.length} bytes`);
|
||||
}
|
||||
} finally {
|
||||
await rm(work, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Screenshots a running page with headless Chrome, and reports whatever the
|
||||
* page logged to the console while doing it.
|
||||
*
|
||||
* bun scripts/screenshot.ts <url> <out.png> [--w=1280] [--h=900]
|
||||
* [--wait=1500] [--full] [--click=<selector>]
|
||||
* [--eval=<js>|--eval-file=<path>] [--dark] [--reload]
|
||||
* [--profile=<dir>] [--eval-after-file=<path>]
|
||||
*
|
||||
* Why this exists: ARCHITECTURE.md and TODO.md both say nothing here can run a
|
||||
* browser, so appearance has never been verified. Chrome IS available, but its
|
||||
* plain `--screenshot` flag fires on the load event — before this app's async
|
||||
* IndexedDB read resolves, which captures an empty page. Driving CDP directly
|
||||
* lets us wait, act, and read console errors.
|
||||
*
|
||||
* This does NOT replace a human: it cannot judge feel, and it renders with one
|
||||
* font stack on one platform. It does prove a layout exists and is not broken.
|
||||
*/
|
||||
const [url, out, ...flags] = process.argv.slice(2);
|
||||
if (!url || !out) {
|
||||
console.error("usage: bun scripts/screenshot.ts <url> <out.png> [--w=] [--h=] [--wait=] [--full] [--click=] [--eval=] [--dark]");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const flag = (name: string, fallback?: string) =>
|
||||
flags.find((f) => f.startsWith(`--${name}=`))?.slice(name.length + 3) ?? fallback;
|
||||
const has = (name: string) => flags.includes(`--${name}`);
|
||||
|
||||
const width = Number(flag("w", "1280"));
|
||||
const height = Number(flag("h", "900"));
|
||||
const waitMs = Number(flag("wait", "1500"));
|
||||
|
||||
const chrome = Bun.spawn(
|
||||
[
|
||||
process.env.CHROME ?? "google-chrome",
|
||||
"--headless=new",
|
||||
"--disable-gpu",
|
||||
"--no-sandbox",
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
"--hide-scrollbars",
|
||||
"--force-device-scale-factor=1",
|
||||
"--remote-debugging-port=0",
|
||||
// A fresh profile each run means empty IndexedDB, empty caches and NO
|
||||
// registered service worker — which makes anything stateful across loads
|
||||
// (an update prompt, a migration, an install) impossible to reproduce.
|
||||
...(flag("profile") ? [`--user-data-dir=${flag("profile")}`] : []),
|
||||
`--window-size=${width},${height}`,
|
||||
...(has("dark") ? ["--force-dark-mode", "--enable-features=WebContentsForceDark"] : []),
|
||||
"about:blank",
|
||||
],
|
||||
{ stdout: "ignore", stderr: "pipe" },
|
||||
);
|
||||
|
||||
/** Chrome announces its debugging endpoint on stderr, once, at startup. */
|
||||
async function debuggerUrl(): Promise<string> {
|
||||
const reader = chrome.stderr.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (Date.now() < deadline) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const match = /ws:\/\/[^\s]+/.exec(buffer);
|
||||
if (match) return match[0];
|
||||
}
|
||||
throw new Error("Chrome never printed a DevTools endpoint");
|
||||
}
|
||||
|
||||
const browserWs = await debuggerUrl();
|
||||
const host = new URL(browserWs).host;
|
||||
|
||||
// The freshly launched about:blank tab is the one to drive.
|
||||
const targets = (await (await fetch(`http://${host}/json/list`)).json()) as Array<{
|
||||
type: string;
|
||||
webSocketDebuggerUrl: string;
|
||||
}>;
|
||||
const page = targets.find((t) => t.type === "page");
|
||||
if (!page) throw new Error("No page target to attach to");
|
||||
|
||||
const socket = new WebSocket(page.webSocketDebuggerUrl);
|
||||
await new Promise((resolve, reject) => {
|
||||
socket.onopen = resolve;
|
||||
socket.onerror = reject;
|
||||
});
|
||||
|
||||
let nextId = 0;
|
||||
const pending = new Map<number, (result: unknown) => void>();
|
||||
const events: Array<{ method: string; params: any }> = [];
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
const message = JSON.parse(String(event.data));
|
||||
if (message.id !== undefined) {
|
||||
const settle = pending.get(message.id);
|
||||
pending.delete(message.id);
|
||||
settle?.(message);
|
||||
} else {
|
||||
events.push({ method: message.method, params: message.params });
|
||||
}
|
||||
};
|
||||
|
||||
/** Rejects on a CDP error rather than resolving undefined, which is otherwise
|
||||
* indistinguishable from a command that legitimately returns nothing. */
|
||||
function send(method: string, params: Record<string, unknown> = {}): Promise<any> {
|
||||
const id = ++nextId;
|
||||
// Register BEFORE sending: a reply that arrives first would otherwise find
|
||||
// no handler and the promise would never settle.
|
||||
const reply = new Promise<any>((resolve, reject) => {
|
||||
pending.set(id, (message: any) => {
|
||||
if (message.error) reject(new Error(`${method}: ${message.error.message}`));
|
||||
else resolve(message.result);
|
||||
});
|
||||
});
|
||||
socket.send(JSON.stringify({ id, method, params }));
|
||||
return reply;
|
||||
}
|
||||
|
||||
await send("Page.enable");
|
||||
await send("Runtime.enable");
|
||||
await send("Log.enable");
|
||||
await send("Emulation.setDeviceMetricsOverride", {
|
||||
width,
|
||||
height,
|
||||
deviceScaleFactor: 1,
|
||||
mobile: width < 700,
|
||||
});
|
||||
if (has("dark")) {
|
||||
await send("Emulation.setEmulatedMedia", {
|
||||
features: [{ name: "prefers-color-scheme", value: "dark" }],
|
||||
});
|
||||
// shadcn's dark palette is class-based (`.dark`), not media-query based, so
|
||||
// emulating the media feature alone changes nothing. Add the class too.
|
||||
await send("Page.addScriptToEvaluateOnNewDocument", {
|
||||
source: `document.addEventListener("DOMContentLoaded", () =>
|
||||
document.documentElement.classList.add("dark"));`,
|
||||
});
|
||||
}
|
||||
|
||||
await send("Page.navigate", { url });
|
||||
// Wait for the load event, then for the app's own async work (the IndexedDB
|
||||
// read, which plain --screenshot never waits for).
|
||||
const loadDeadline = Date.now() + 20_000;
|
||||
while (!events.some((e) => e.method === "Page.loadEventFired") && Date.now() < loadDeadline) {
|
||||
await Bun.sleep(50);
|
||||
}
|
||||
await Bun.sleep(waitMs);
|
||||
|
||||
const evalFile = flag("eval-file");
|
||||
const evalJs = evalFile ? await Bun.file(evalFile).text() : flag("eval");
|
||||
if (evalJs) {
|
||||
const result = await send("Runtime.evaluate", { expression: evalJs, awaitPromise: true });
|
||||
console.log("eval:", JSON.stringify(result?.result?.value ?? result?.result?.description ?? null));
|
||||
await Bun.sleep(400);
|
||||
}
|
||||
|
||||
// --reload exists for --eval scripts that seed IndexedDB: the app reads its
|
||||
// dataset once at boot, so it has to boot again to see what was written.
|
||||
if (has("reload")) {
|
||||
events.length = 0;
|
||||
await send("Page.navigate", { url });
|
||||
const again = Date.now() + 20_000;
|
||||
while (!events.some((e) => e.method === "Page.loadEventFired") && Date.now() < again) {
|
||||
await Bun.sleep(50);
|
||||
}
|
||||
await Bun.sleep(waitMs);
|
||||
}
|
||||
|
||||
const clickSelector = flag("click");
|
||||
if (clickSelector) {
|
||||
const clicked = await send("Runtime.evaluate", {
|
||||
expression: `(() => { const el = document.querySelector(${JSON.stringify(clickSelector)});
|
||||
if (!el) return "not found"; el.click(); return "clicked"; })()`,
|
||||
});
|
||||
console.log(`click ${clickSelector}: ${clicked?.result?.value}`);
|
||||
await Bun.sleep(700);
|
||||
}
|
||||
|
||||
// Runs after the reload, so a flow that ends in a navigation can be observed
|
||||
// on the other side of it — an update prompt applying itself, a migration.
|
||||
const evalAfterFile = flag("eval-after-file");
|
||||
if (evalAfterFile) {
|
||||
const source = await Bun.file(evalAfterFile).text();
|
||||
const result = await send("Runtime.evaluate", {
|
||||
expression: source,
|
||||
awaitPromise: true,
|
||||
});
|
||||
console.log(
|
||||
"eval-after:",
|
||||
JSON.stringify(result?.result?.value ?? result?.result?.description ?? null),
|
||||
);
|
||||
}
|
||||
|
||||
const shot = await send("Page.captureScreenshot", {
|
||||
format: "png",
|
||||
captureBeyondViewport: has("full"),
|
||||
});
|
||||
await Bun.write(out, Buffer.from(shot.data, "base64"));
|
||||
|
||||
// Console output is the point of doing this over a plain --screenshot: a page
|
||||
// that renders blank because of a thrown error looks identical to an empty one.
|
||||
const logs = events
|
||||
.filter((e) => e.method === "Runtime.consoleAPICalled" || e.method === "Log.entryAdded")
|
||||
.map((e) =>
|
||||
e.method === "Log.entryAdded"
|
||||
? `[${e.params.entry.level}] ${e.params.entry.text}`
|
||||
: `[${e.params.type}] ${(e.params.args ?? [])
|
||||
.map((a: any) => a.value ?? a.description ?? a.type)
|
||||
.join(" ")}`,
|
||||
);
|
||||
const exceptions = events
|
||||
.filter((e) => e.method === "Runtime.exceptionThrown")
|
||||
.map((e) => `[uncaught] ${e.params.exceptionDetails?.exception?.description ?? e.params.exceptionDetails?.text}`);
|
||||
|
||||
console.log(`wrote ${out} (${width}x${height})`);
|
||||
for (const line of [...exceptions, ...logs]) console.log(" " + line);
|
||||
if (exceptions.length === 0 && logs.length === 0) console.log(" (console clean)");
|
||||
|
||||
socket.close();
|
||||
chrome.kill();
|
||||
await chrome.exited;
|
||||
@@ -0,0 +1,80 @@
|
||||
import { test, expect, describe } from "bun:test";
|
||||
|
||||
/**
|
||||
* CLAUDE.md's hardest-won rule, made mechanical.
|
||||
*
|
||||
* shadcn uses several tokens as a BACKGROUND with a paired FOREGROUND for the
|
||||
* text on it. Redefining one without the other repaints the background and
|
||||
* leaves the text at the old colour — which is how overriding a bare
|
||||
* `--accent` once dropped hover contrast below AA across the whole app. The
|
||||
* damage is invisible in code review and invisible in a passing test suite.
|
||||
*
|
||||
* So: in any block that redefines one of these, its pair must be redefined in
|
||||
* the same block.
|
||||
*/
|
||||
const PAIRS = [
|
||||
["--background", "--foreground"],
|
||||
["--card", "--card-foreground"],
|
||||
["--popover", "--popover-foreground"],
|
||||
["--primary", "--primary-foreground"],
|
||||
["--secondary", "--secondary-foreground"],
|
||||
["--muted", "--muted-foreground"],
|
||||
["--accent", "--accent-foreground"],
|
||||
] as const;
|
||||
|
||||
/** Top-level `selector { ... }` blocks. Nested at-rules are not used here. */
|
||||
function blocks(css: string): { selector: string; body: string }[] {
|
||||
const found: { selector: string; body: string }[] = [];
|
||||
const re = /([^{}]+)\{([^{}]*)\}/g;
|
||||
for (const match of css.matchAll(re)) {
|
||||
found.push({ selector: match[1]!.trim(), body: match[2]! });
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
export function findUnpairedTokens(css: string): string[] {
|
||||
const problems: string[] = [];
|
||||
for (const { selector, body } of blocks(css)) {
|
||||
const defines = (token: string) =>
|
||||
new RegExp(`(^|[;\\s])${token}\\s*:`).test(body);
|
||||
for (const [background, foreground] of PAIRS) {
|
||||
if (defines(background) && !defines(foreground)) {
|
||||
problems.push(`${selector} sets ${background} without ${foreground}`);
|
||||
}
|
||||
if (defines(foreground) && !defines(background)) {
|
||||
problems.push(`${selector} sets ${foreground} without ${background}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
describe("the paired-token guard itself", () => {
|
||||
test("catches a background redefined without its foreground", () => {
|
||||
expect(findUnpairedTokens(":root { --accent: #eee; }")).toEqual([
|
||||
":root sets --accent without --accent-foreground",
|
||||
]);
|
||||
});
|
||||
|
||||
test("catches a foreground redefined without its background", () => {
|
||||
expect(findUnpairedTokens(".dark { --primary-foreground: #fff; }")).toEqual([
|
||||
".dark sets --primary-foreground without --primary",
|
||||
]);
|
||||
});
|
||||
|
||||
test("accepts a pair defined together", () => {
|
||||
expect(
|
||||
findUnpairedTokens(":root { --accent: #eee; --accent-foreground: #111; }"),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("is not fooled by a token whose name merely ends the same way", () => {
|
||||
// --sidebar-accent must not be read as --accent.
|
||||
expect(findUnpairedTokens(":root { --sidebar-accent: #eee; }")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
test("src/index.css never redefines a shadcn token without its pair", async () => {
|
||||
const css = await Bun.file("src/index.css").text();
|
||||
expect(findUnpairedTokens(css)).toEqual([]);
|
||||
});
|
||||
@@ -10,6 +10,13 @@ import {
|
||||
import { foldBalances, figuresFor } from "./fold.ts";
|
||||
import type { Dataset, FxRate, Post, Rule } from "./types.ts";
|
||||
|
||||
/** Digits for the currencies these tests use; all real-world 2dp. */
|
||||
const TEST_CURRENCIES = [
|
||||
{ code: "DKK", digits: 2, symbol: "kr" },
|
||||
{ code: "USD", digits: 2, symbol: "$" },
|
||||
{ code: "EUR", digits: 2, symbol: "\u20ac" },
|
||||
];
|
||||
|
||||
const rates: FxRate[] = [
|
||||
{ currency: "EUR", baseUnitsPerOne: 7.46, updatedAt: "2026-09-01", source: "manual" },
|
||||
{ currency: "USD", baseUnitsPerOne: 6.9, updatedAt: "2026-09-01", source: "manual" },
|
||||
@@ -36,6 +43,7 @@ function post(overrides: Partial<Post> = {}): Post {
|
||||
function dataset(overrides: Partial<Dataset> = {}): Dataset {
|
||||
return {
|
||||
settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 2 },
|
||||
currencies: TEST_CURRENCIES,
|
||||
fxRates: rates,
|
||||
posts: [post()],
|
||||
months: [
|
||||
@@ -48,21 +56,21 @@ function dataset(overrides: Partial<Dataset> = {}): Dataset {
|
||||
|
||||
describe("resolveRule", () => {
|
||||
test("a fixed rule in base currency is its amount", () => {
|
||||
expect(resolveRule(fixed(400), 20000, "DKK", rates)).toBe(400);
|
||||
expect(resolveRule(fixed(400), 20000, "DKK", rates, TEST_CURRENCIES)).toBe(400);
|
||||
});
|
||||
|
||||
test("a fixed rule in a foreign currency converts to base", () => {
|
||||
expect(
|
||||
resolveRule({ kind: "fixed", amount: { amount: 50, currency: "EUR" } }, 20000, "DKK", rates),
|
||||
resolveRule({ kind: "fixed", amount: { amount: 50, currency: "EUR" } }, 20000, "DKK", rates, TEST_CURRENCIES),
|
||||
).toBe(373);
|
||||
});
|
||||
|
||||
test("a percentage rule resolves against income", () => {
|
||||
expect(resolveRule(pct(25), 20000, "DKK", rates)).toBe(5000);
|
||||
expect(resolveRule(pct(25), 20000, "DKK", rates, TEST_CURRENCIES)).toBe(5000);
|
||||
});
|
||||
|
||||
test("a percentage above 100 is permitted", () => {
|
||||
expect(resolveRule(pct(150), 1000, "DKK", rates)).toBe(1500);
|
||||
expect(resolveRule(pct(150), 1000, "DKK", rates, TEST_CURRENCIES)).toBe(1500);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -177,6 +185,7 @@ describe("the fold across a rule change", () => {
|
||||
// rollover carrying correctly across the boundary.
|
||||
const data: Dataset = {
|
||||
settings: { baseCurrency: "DKK", foldStartMonth: "2026-05", schemaVersion: 2 },
|
||||
currencies: TEST_CURRENCIES,
|
||||
fxRates: [],
|
||||
posts: [
|
||||
post({
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { toBase } from "./fx.ts";
|
||||
import { roundMoney } from "./money.ts";
|
||||
import { digitsFor } from "./currencies.ts";
|
||||
import { compareMonths } from "./months.ts";
|
||||
import type {
|
||||
Currency,
|
||||
CurrencyDef,
|
||||
Dataset,
|
||||
FxRate,
|
||||
Month,
|
||||
@@ -19,11 +21,12 @@ export function resolveRule(
|
||||
income: number,
|
||||
baseCurrency: Currency,
|
||||
rates: FxRate[],
|
||||
currencies: readonly CurrencyDef[],
|
||||
): number {
|
||||
if (rule.kind === "fixed") {
|
||||
return toBase(rule.amount, baseCurrency, rates);
|
||||
return toBase(rule.amount, baseCurrency, rates, currencies);
|
||||
}
|
||||
return roundMoney((income * rule.percent) / 100, baseCurrency);
|
||||
return roundMoney((income * rule.percent) / 100, digitsFor(currencies, baseCurrency));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,7 +68,12 @@ export function isOverridden(month: Month | undefined, postId: PostId): boolean
|
||||
export function incomeFor(dataset: Dataset, monthId: MonthId): number {
|
||||
const month = dataset.months.find((m) => m.id === monthId);
|
||||
if (!month) return 0;
|
||||
return toBase(month.income, dataset.settings.baseCurrency, dataset.fxRates);
|
||||
return toBase(
|
||||
month.income,
|
||||
dataset.settings.baseCurrency,
|
||||
dataset.fxRates,
|
||||
dataset.currencies,
|
||||
);
|
||||
}
|
||||
|
||||
export function allocationFor(
|
||||
@@ -83,5 +91,6 @@ export function allocationFor(
|
||||
incomeFor(dataset, monthId),
|
||||
dataset.settings.baseCurrency,
|
||||
dataset.fxRates,
|
||||
dataset.currencies,
|
||||
);
|
||||
}
|
||||
+25
-13
@@ -8,6 +8,13 @@ import {
|
||||
import { roundMoney } from "./money.ts";
|
||||
import type { Dataset, FxRate, Purchase } from "./types.ts";
|
||||
|
||||
/** Digits for the currencies these tests use; all real-world 2dp. */
|
||||
const TEST_CURRENCIES = [
|
||||
{ code: "DKK", digits: 2, symbol: "kr" },
|
||||
{ code: "USD", digits: 2, symbol: "$" },
|
||||
{ code: "EUR", digits: 2, symbol: "\u20ac" },
|
||||
];
|
||||
|
||||
const rates: FxRate[] = [
|
||||
{ currency: "EUR", baseUnitsPerOne: 7.46, updatedAt: "2026-09-01", source: "manual" },
|
||||
{ currency: "USD", baseUnitsPerOne: 6.9, updatedAt: "2026-09-01", source: "manual" },
|
||||
@@ -28,7 +35,7 @@ function purchase(overrides: Partial<Purchase> = {}): Purchase {
|
||||
|
||||
describe("splitPartsOfTotal", () => {
|
||||
test("a single split takes the whole total", () => {
|
||||
expect(splitPartsOfTotal(purchase())).toEqual([200]);
|
||||
expect(splitPartsOfTotal(purchase(), 2)).toEqual([200]);
|
||||
});
|
||||
|
||||
test("percent mode divides proportionally", () => {
|
||||
@@ -39,7 +46,8 @@ describe("splitPartsOfTotal", () => {
|
||||
{ postId: "events", value: 40, absorbsRemainder: false },
|
||||
],
|
||||
}),
|
||||
);
|
||||
2,
|
||||
);
|
||||
expect(parts).toEqual([120, 80]);
|
||||
});
|
||||
|
||||
@@ -52,7 +60,8 @@ describe("splitPartsOfTotal", () => {
|
||||
{ postId: "events", value: 50, absorbsRemainder: true },
|
||||
],
|
||||
}),
|
||||
);
|
||||
2,
|
||||
);
|
||||
expect(parts).toEqual([120, 80]);
|
||||
});
|
||||
|
||||
@@ -66,8 +75,9 @@ describe("splitPartsOfTotal", () => {
|
||||
{ postId: "c", value: 33.34, absorbsRemainder: true },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(roundMoney(parts.reduce((a, b) => a + b, 0))).toBe(100);
|
||||
2,
|
||||
);
|
||||
expect(roundMoney(parts.reduce((a, b) => a + b, 0), 2)).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -121,14 +131,14 @@ describe("sliceAmountForMonth", () => {
|
||||
|
||||
describe("chargesForPurchaseInMonth", () => {
|
||||
test("a simple purchase charges one post", () => {
|
||||
expect(chargesForPurchaseInMonth(purchase(), "2026-09", "DKK", rates)).toEqual([
|
||||
expect(chargesForPurchaseInMonth(purchase(), "2026-09", "DKK", rates, TEST_CURRENCIES)).toEqual([
|
||||
{ postId: "food", amount: 200 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("converts a foreign-currency purchase into base", () => {
|
||||
const eur = purchase({ total: { amount: 10, currency: "EUR" } });
|
||||
expect(chargesForPurchaseInMonth(eur, "2026-09", "DKK", rates)).toEqual([
|
||||
expect(chargesForPurchaseInMonth(eur, "2026-09", "DKK", rates, TEST_CURRENCIES)).toEqual([
|
||||
{ postId: "food", amount: 74.6 },
|
||||
]);
|
||||
});
|
||||
@@ -147,11 +157,11 @@ describe("chargesForPurchaseInMonth", () => {
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(chargesForPurchaseInMonth(financedSplit, "2026-10", "DKK", rates)).toEqual([
|
||||
expect(chargesForPurchaseInMonth(financedSplit, "2026-10", "DKK", rates, TEST_CURRENCIES)).toEqual([
|
||||
{ postId: "games", amount: 700 },
|
||||
{ postId: "events", amount: 300 },
|
||||
]);
|
||||
expect(chargesForPurchaseInMonth(financedSplit, "2026-11", "DKK", rates)).toEqual([
|
||||
expect(chargesForPurchaseInMonth(financedSplit, "2026-11", "DKK", rates, TEST_CURRENCIES)).toEqual([
|
||||
{ postId: "games", amount: 1400 },
|
||||
{ postId: "events", amount: 600 },
|
||||
]);
|
||||
@@ -169,14 +179,14 @@ describe("chargesForPurchaseInMonth", () => {
|
||||
slices: [{ month: "2026-10", amount: { amount: 600, currency: "DKK" } }],
|
||||
},
|
||||
});
|
||||
expect(chargesForPurchaseInMonth(financed, "2026-10", "DKK", rates)).toEqual([
|
||||
expect(chargesForPurchaseInMonth(financed, "2026-10", "DKK", rates, TEST_CURRENCIES)).toEqual([
|
||||
{ postId: "games", amount: 400 },
|
||||
{ postId: "events", amount: 200 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("charges in a month the purchase does not touch are empty", () => {
|
||||
expect(chargesForPurchaseInMonth(purchase(), "2026-01", "DKK", rates)).toEqual([]);
|
||||
expect(chargesForPurchaseInMonth(purchase(), "2026-01", "DKK", rates, TEST_CURRENCIES)).toEqual([]);
|
||||
});
|
||||
|
||||
test("slice charges always sum to the slice total", () => {
|
||||
@@ -188,8 +198,8 @@ describe("chargesForPurchaseInMonth", () => {
|
||||
{ postId: "c", value: 1, absorbsRemainder: true },
|
||||
],
|
||||
});
|
||||
const charges = chargesForPurchaseInMonth(odd, "2026-09", "DKK", rates);
|
||||
expect(roundMoney(charges.reduce((sum, c) => sum + c.amount, 0))).toBe(100);
|
||||
const charges = chargesForPurchaseInMonth(odd, "2026-09", "DKK", rates, TEST_CURRENCIES);
|
||||
expect(roundMoney(charges.reduce((sum, c) => sum + c.amount, 0), 2)).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -197,6 +207,7 @@ describe("chargesForMonth", () => {
|
||||
test("sums charges per post across all purchases", () => {
|
||||
const data: Dataset = {
|
||||
settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 1 },
|
||||
currencies: TEST_CURRENCIES,
|
||||
fxRates: rates,
|
||||
posts: [],
|
||||
months: [],
|
||||
@@ -214,6 +225,7 @@ describe("chargesForMonth", () => {
|
||||
test("a month with no purchases yields an empty map", () => {
|
||||
const data: Dataset = {
|
||||
settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 1 },
|
||||
currencies: TEST_CURRENCIES,
|
||||
fxRates: rates,
|
||||
posts: [],
|
||||
months: [],
|
||||
|
||||
+15
-8
@@ -1,8 +1,10 @@
|
||||
import { toBase } from "./fx.ts";
|
||||
import { digitsFor } from "./currencies.ts";
|
||||
import { distributeByAmount, distributeByWeight, roundMoney } from "./money.ts";
|
||||
import { compareMonths, monthOf } from "./months.ts";
|
||||
import type {
|
||||
Currency,
|
||||
CurrencyDef,
|
||||
Dataset,
|
||||
FxRate,
|
||||
Money,
|
||||
@@ -31,14 +33,13 @@ export function remainderIndexOf(purchase: Purchase): number {
|
||||
* How the whole purchase divides across its posts, in the purchase's own
|
||||
* currency. Parts sum exactly to the total.
|
||||
*/
|
||||
export function splitPartsOfTotal(purchase: Purchase): number[] {
|
||||
export function splitPartsOfTotal(purchase: Purchase, digits: number): number[] {
|
||||
const index = remainderIndexOf(purchase);
|
||||
const values = purchase.splits.map((s) => s.value);
|
||||
const total = purchase.total.amount;
|
||||
const currency = purchase.total.currency;
|
||||
return purchase.splitMode === "percent"
|
||||
? distributeByWeight(total, values, index, currency)
|
||||
: distributeByAmount(total, values, index, currency);
|
||||
? distributeByWeight(total, values, index, digits)
|
||||
: distributeByAmount(total, values, index, digits);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,14 +73,18 @@ export function chargesForPurchaseInMonth(
|
||||
monthId: MonthId,
|
||||
baseCurrency: Currency,
|
||||
rates: FxRate[],
|
||||
currencies: readonly CurrencyDef[],
|
||||
): Charge[] {
|
||||
const slice = sliceAmountForMonth(purchase, monthId);
|
||||
if (!slice) return [];
|
||||
|
||||
const sliceBase = toBase(slice, baseCurrency, rates);
|
||||
const sliceBase = toBase(slice, baseCurrency, rates, currencies);
|
||||
const index = remainderIndexOf(purchase);
|
||||
const weights = splitPartsOfTotal(purchase);
|
||||
const parts = distributeByWeight(sliceBase, weights, index, baseCurrency);
|
||||
// Split proportions are computed in the PURCHASE's own currency, then the
|
||||
// month's slice is distributed to those proportions in the BASE currency —
|
||||
// so the two roundings use different digits.
|
||||
const weights = splitPartsOfTotal(purchase, digitsFor(currencies, purchase.total.currency));
|
||||
const parts = distributeByWeight(sliceBase, weights, index, digitsFor(currencies, baseCurrency));
|
||||
|
||||
return purchase.splits.map((split, i) => ({
|
||||
postId: split.postId,
|
||||
@@ -93,18 +98,20 @@ export function chargesForMonth(
|
||||
monthId: MonthId,
|
||||
): Map<PostId, number> {
|
||||
const totals = new Map<PostId, number>();
|
||||
const baseDigits = digitsFor(dataset.currencies, dataset.settings.baseCurrency);
|
||||
for (const purchase of dataset.purchases) {
|
||||
const charges = chargesForPurchaseInMonth(
|
||||
purchase,
|
||||
monthId,
|
||||
dataset.settings.baseCurrency,
|
||||
dataset.fxRates,
|
||||
dataset.currencies,
|
||||
);
|
||||
for (const charge of charges) {
|
||||
const previous = totals.get(charge.postId) ?? 0;
|
||||
totals.set(
|
||||
charge.postId,
|
||||
roundMoney(previous + charge.amount, dataset.settings.baseCurrency),
|
||||
roundMoney(previous + charge.amount, baseDigits),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { test, expect, describe } from "bun:test";
|
||||
import { digitsFor, currencyCodes, findCurrency, normalizeCurrencyCode } from "./currencies.ts";
|
||||
import type { CurrencyDef } from "./types.ts";
|
||||
|
||||
const TABLE: CurrencyDef[] = [
|
||||
{ code: "DKK", digits: 2, symbol: "kr" },
|
||||
{ code: "JPY", digits: 0, symbol: "¥" },
|
||||
{ code: "KWD", digits: 3 },
|
||||
];
|
||||
|
||||
describe("digitsFor", () => {
|
||||
test("reads the digits the owner recorded, not an assumed 2", () => {
|
||||
expect(digitsFor(TABLE, "DKK")).toBe(2);
|
||||
expect(digitsFor(TABLE, "JPY")).toBe(0);
|
||||
expect(digitsFor(TABLE, "KWD")).toBe(3);
|
||||
});
|
||||
|
||||
/**
|
||||
* Deep inside the fold a throw would take the whole balance view down over
|
||||
* a data problem the import validator already rejects at the boundary. Two
|
||||
* places is right for almost every currency in existence.
|
||||
*/
|
||||
test("an unknown currency falls back to two places rather than throwing", () => {
|
||||
expect(digitsFor(TABLE, "XYZ")).toBe(2);
|
||||
expect(digitsFor([], "DKK")).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeCurrencyCode", () => {
|
||||
test("codes are identity, so case and padding cannot fork one currency in two", () => {
|
||||
expect(normalizeCurrencyCode(" usd ")).toBe("USD");
|
||||
expect(normalizeCurrencyCode("Jpy")).toBe("JPY");
|
||||
});
|
||||
});
|
||||
|
||||
test("currencyCodes keeps the owner's order", () => {
|
||||
expect(currencyCodes(TABLE)).toEqual(["DKK", "JPY", "KWD"]);
|
||||
});
|
||||
|
||||
test("findCurrency returns the definition, or nothing", () => {
|
||||
expect(findCurrency(TABLE, "JPY")?.symbol).toBe("¥");
|
||||
expect(findCurrency(TABLE, "XYZ")).toBeUndefined();
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
DEFAULT_CURRENCY_DIGITS,
|
||||
type Currency,
|
||||
type CurrencyDef,
|
||||
type Dataset,
|
||||
} from "./types.ts";
|
||||
|
||||
/**
|
||||
* How many decimals a currency rounds to.
|
||||
*
|
||||
* Digits used to be a module-level constant table, which made "add a
|
||||
* currency" a code change. They now live in the dataset, so every rounding
|
||||
* site has to be told which digits to use — that threading is deliberate, and
|
||||
* the reason `roundMoney` takes a number rather than a code.
|
||||
*
|
||||
* An undefined currency falls back to two places rather than throwing. A
|
||||
* missing definition is a data problem the import validator rejects at the
|
||||
* boundary; deep inside the fold, two decimals is right for almost every
|
||||
* currency and a throw would take the whole balance view down.
|
||||
*/
|
||||
export function digitsFor(
|
||||
currencies: readonly CurrencyDef[],
|
||||
code: Currency,
|
||||
): number {
|
||||
const found = currencies.find((currency) => currency.code === code);
|
||||
return found ? found.digits : DEFAULT_CURRENCY_DIGITS;
|
||||
}
|
||||
|
||||
/** The codes, in the order the owner arranged them. */
|
||||
export function currencyCodes(currencies: readonly CurrencyDef[]): Currency[] {
|
||||
return currencies.map((currency) => currency.code);
|
||||
}
|
||||
|
||||
export function findCurrency(
|
||||
currencies: readonly CurrencyDef[],
|
||||
code: Currency,
|
||||
): CurrencyDef | undefined {
|
||||
return currencies.find((currency) => currency.code === code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Uppercased and trimmed. Codes are identity here — they key the FX table and
|
||||
* every stored Money — so "usd" and "USD" must never become two currencies.
|
||||
*/
|
||||
export function normalizeCurrencyCode(raw: string): Currency {
|
||||
return raw.trim().toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Everywhere `code` is referenced by stored data, described for a human.
|
||||
*
|
||||
* The same reasoning as posts being archived rather than deleted: stored Money
|
||||
* carries the code, so removing a currency in use would leave amounts pointing
|
||||
* at a definition that no longer exists — and `parseDatasetJson` would then
|
||||
* refuse the owner's own exported file. Returning WHERE it is used lets the UI
|
||||
* explain the refusal instead of just denying it.
|
||||
*/
|
||||
export function currencyUsage(dataset: Dataset, code: Currency): string[] {
|
||||
const used: string[] = [];
|
||||
|
||||
if (dataset.settings.baseCurrency === code) used.push("the base currency");
|
||||
|
||||
for (const post of dataset.posts) {
|
||||
if (post.currency === code) used.push(`post "${post.name}"`);
|
||||
for (const version of post.rules) {
|
||||
if (version.rule.kind === "fixed" && version.rule.amount.currency === code) {
|
||||
used.push(`post "${post.name}" rule from ${version.from}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const month of dataset.months) {
|
||||
if (month.income.currency === code) used.push(`month ${month.id}`);
|
||||
for (const [postId, rule] of Object.entries(month.ruleOverrides)) {
|
||||
if (rule.kind === "fixed" && rule.amount.currency === code) {
|
||||
const name = dataset.posts.find((p) => p.id === postId)?.name ?? postId;
|
||||
used.push(`${month.id} override for "${name}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const purchase of dataset.purchases) {
|
||||
if (purchase.total.currency === code) used.push(`purchase "${purchase.description}"`);
|
||||
for (const slice of purchase.schedule?.slices ?? []) {
|
||||
if (slice.amount.currency === code) {
|
||||
used.push(`purchase "${purchase.description}" slice ${slice.month}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(used)];
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { test, expect, describe } from "bun:test";
|
||||
import { roundMoney, distributeByWeight, distributeByAmount } from "./money.ts";
|
||||
import { toBase, fromBase } from "./fx.ts";
|
||||
import { equalSlices, sliceTotal } from "./plans.ts";
|
||||
import { resolveRule } from "./allocation.ts";
|
||||
import { foldBalances, figuresFor } from "./fold.ts";
|
||||
import { monthView } from "./views.ts";
|
||||
import type { CurrencyDef, Dataset } from "./types.ts";
|
||||
|
||||
/**
|
||||
* Every currency the app shipped with has two decimal places, so code that
|
||||
* hardcodes 2 is indistinguishable from code that reads the dataset. Now that
|
||||
* the owner can define currencies, that stops being true — and a zero-decimal
|
||||
* currency is the only thing that can prove digits actually flow from the
|
||||
* dataset to each rounding site rather than being assumed.
|
||||
*
|
||||
* JPY is the real-world case: yen have no minor unit, so ¥3333.3 is not a
|
||||
* number that can exist.
|
||||
*/
|
||||
const JPY: CurrencyDef = { code: "JPY", digits: 0, symbol: "¥" };
|
||||
const USD: CurrencyDef = { code: "USD", digits: 2, symbol: "$" };
|
||||
const CURRENCIES = [JPY, USD];
|
||||
|
||||
describe("rounding takes its digits from the dataset", () => {
|
||||
test("roundMoney rounds to the digits it is given", () => {
|
||||
expect(roundMoney(3333.3, 0)).toBe(3333);
|
||||
expect(roundMoney(3333.335, 2)).toBe(3333.34);
|
||||
// 1.234, not 1.235: the double nearest 1.2345 is slightly BELOW it, and
|
||||
// toFixed rounds the double's actual value. That is the documented
|
||||
// trade-off in money.ts — predictable, if occasionally surprising.
|
||||
expect(roundMoney(1.2345, 3)).toBe(1.234);
|
||||
});
|
||||
|
||||
test("a percentage allocation in a zero-decimal currency yields whole units", () => {
|
||||
const rate = resolveRule(
|
||||
{ kind: "percentOfIncome", percent: 33.333 },
|
||||
10_000,
|
||||
"JPY",
|
||||
[],
|
||||
CURRENCIES,
|
||||
);
|
||||
expect(rate).toBe(3333);
|
||||
expect(Number.isInteger(rate)).toBe(true);
|
||||
});
|
||||
|
||||
test("an FX conversion into a zero-decimal base yields whole units", () => {
|
||||
const rates = [
|
||||
{ currency: "USD", baseUnitsPerOne: 151.234567, updatedAt: "2026-01-01", source: "manual" as const },
|
||||
];
|
||||
expect(toBase({ amount: 10, currency: "USD" }, "JPY", rates, CURRENCIES)).toBe(1512);
|
||||
// And back out: 1512 / 151.234567 = 9.9977..., rounded to USD's TWO
|
||||
// decimals, so 10 — not the three-decimal 9.998.
|
||||
expect(fromBase(1512, "USD", "JPY", rates, CURRENCIES)).toBe(10);
|
||||
});
|
||||
|
||||
test("a split distributed in a zero-decimal currency still sums exactly", () => {
|
||||
const parts = distributeByWeight(1000, [1, 1, 1], 2, 0);
|
||||
expect(parts).toEqual([333, 333, 334]);
|
||||
expect(parts.reduce((a, b) => a + b, 0)).toBe(1000);
|
||||
expect(parts.every(Number.isInteger)).toBe(true);
|
||||
});
|
||||
|
||||
test("fixed amounts distributed in a zero-decimal currency still sum exactly", () => {
|
||||
const parts = distributeByAmount(1000, [333.4, 333.4, 0], 2, 0);
|
||||
expect(parts.reduce((a, b) => a + b, 0)).toBe(1000);
|
||||
expect(parts.every(Number.isInteger)).toBe(true);
|
||||
});
|
||||
|
||||
test("a finance plan divides into whole units and sums exactly", () => {
|
||||
const slices = equalSlices({ amount: 1000, currency: "JPY" }, "2026-01", 3, 0);
|
||||
expect(slices.map((s) => s.amount.amount)).toEqual([333, 333, 334]);
|
||||
expect(sliceTotal(slices, 0)).toBe(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the fold in a zero-decimal currency", () => {
|
||||
function dataset(): Dataset {
|
||||
return {
|
||||
settings: { baseCurrency: "JPY", foldStartMonth: "2026-01", schemaVersion: 3 },
|
||||
currencies: CURRENCIES,
|
||||
fxRates: [],
|
||||
posts: [
|
||||
{
|
||||
id: "food",
|
||||
name: "Food",
|
||||
order: 0,
|
||||
archived: false,
|
||||
currency: "JPY",
|
||||
rules: [{ from: "2026-01", rule: { kind: "percentOfIncome", percent: 33.333 } }],
|
||||
},
|
||||
],
|
||||
months: [
|
||||
{ id: "2026-01", income: { amount: 10_000, currency: "JPY" }, ruleOverrides: {} },
|
||||
{ id: "2026-02", income: { amount: 10_000, currency: "JPY" }, ruleOverrides: {} },
|
||||
],
|
||||
purchases: [],
|
||||
};
|
||||
}
|
||||
|
||||
test("no figure the fold produces has a fractional yen in it", () => {
|
||||
const fold = foldBalances(dataset(), "2026-02");
|
||||
for (const month of ["2026-01", "2026-02"]) {
|
||||
const figures = figuresFor(fold, "food", month);
|
||||
for (const [name, value] of Object.entries(figures)) {
|
||||
expect(Number.isInteger(value)).toBe(true);
|
||||
expect(`${month} ${name} = ${value}`).toBe(`${month} ${name} = ${Math.round(value)}`);
|
||||
}
|
||||
}
|
||||
expect(figuresFor(fold, "food", "2026-01").allocation).toBe(3333);
|
||||
expect(figuresFor(fold, "food", "2026-02").carriedIn).toBe(3333);
|
||||
});
|
||||
|
||||
test("the month view's totals are whole units too", () => {
|
||||
const view = monthView(dataset(), "2026-01");
|
||||
expect(Number.isInteger(view.totalAllocation)).toBe(true);
|
||||
expect(Number.isInteger(view.unallocated)).toBe(true);
|
||||
expect(view.unallocated).toBe(10_000 - 3333);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,13 @@ import { test, expect, describe } from "bun:test";
|
||||
import { foldBalances, figuresFor } from "./fold.ts";
|
||||
import type { Dataset, Post, Purchase } from "./types.ts";
|
||||
|
||||
/** Digits for the currencies these tests use; all real-world 2dp. */
|
||||
const TEST_CURRENCIES = [
|
||||
{ code: "DKK", digits: 2, symbol: "kr" },
|
||||
{ code: "USD", digits: 2, symbol: "$" },
|
||||
{ code: "EUR", digits: 2, symbol: "\u20ac" },
|
||||
];
|
||||
|
||||
function post(id: string, fixed: number, archived = false): Post {
|
||||
return {
|
||||
id,
|
||||
@@ -28,6 +35,7 @@ function spend(id: string, postId: string, amount: number, date: string): Purcha
|
||||
function dataset(posts: Post[], purchases: Purchase[]): Dataset {
|
||||
return {
|
||||
settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 1 },
|
||||
currencies: TEST_CURRENCIES,
|
||||
fxRates: [],
|
||||
posts,
|
||||
months: [
|
||||
@@ -147,3 +155,19 @@ test("figuresFor returns zeroes for a month outside the fold", () => {
|
||||
remaining: 0,
|
||||
});
|
||||
});
|
||||
|
||||
describe("the empty-figures fallback", () => {
|
||||
// figuresFor returns EMPTY_FIGURES by SHARED REFERENCE for any (post, month)
|
||||
// the fold has no entry for. One consumer mutating that object would move
|
||||
// every other unknown row with it — in a budgeting app, silently.
|
||||
test("cannot be mutated through the value figuresFor hands back", () => {
|
||||
const fold = foldBalances(dataset([post("food", 500)], []), "2026-01");
|
||||
const missing = figuresFor(fold, "no-such-post", "2026-01");
|
||||
|
||||
expect(() => {
|
||||
(missing as { allocation: number }).allocation = 999;
|
||||
}).toThrow(TypeError);
|
||||
|
||||
expect(figuresFor(fold, "another-missing-post", "2026-01").allocation).toBe(0);
|
||||
});
|
||||
});
|
||||
+10
-3
@@ -1,4 +1,5 @@
|
||||
import { allocationFor } from "./allocation.ts";
|
||||
import { digitsFor } from "./currencies.ts";
|
||||
import { chargesForMonth } from "./charges.ts";
|
||||
import { roundMoney } from "./money.ts";
|
||||
import { compareMonths, monthRange } from "./months.ts";
|
||||
@@ -12,12 +13,17 @@ export interface PostMonthFigures {
|
||||
remaining: number;
|
||||
}
|
||||
|
||||
export const EMPTY_FIGURES: PostMonthFigures = {
|
||||
/**
|
||||
* Frozen because `figuresFor` hands this back by shared reference for every
|
||||
* (post, month) the fold has no entry for. Unfrozen, one consumer mutating
|
||||
* its result would move every other unknown row with it.
|
||||
*/
|
||||
export const EMPTY_FIGURES: PostMonthFigures = Object.freeze({
|
||||
carriedIn: 0,
|
||||
allocation: 0,
|
||||
charges: 0,
|
||||
remaining: 0,
|
||||
};
|
||||
});
|
||||
|
||||
export type Fold = Map<MonthId, Map<PostId, PostMonthFigures>>;
|
||||
|
||||
@@ -31,6 +37,7 @@ export type Fold = Map<MonthId, Map<PostId, PostMonthFigures>>;
|
||||
*/
|
||||
export function foldBalances(dataset: Dataset, upToMonth: MonthId): Fold {
|
||||
const { foldStartMonth, baseCurrency } = dataset.settings;
|
||||
const baseDigits = digitsFor(dataset.currencies, baseCurrency);
|
||||
const fold: Fold = new Map();
|
||||
|
||||
if (compareMonths(upToMonth, foldStartMonth) < 0) return fold;
|
||||
@@ -45,7 +52,7 @@ export function foldBalances(dataset: Dataset, upToMonth: MonthId): Fold {
|
||||
const carriedIn = carried.get(post.id) ?? 0;
|
||||
const allocation = allocationFor(dataset, post.id, monthId);
|
||||
const spent = charges.get(post.id) ?? 0;
|
||||
const remaining = roundMoney(carriedIn + allocation - spent, baseCurrency);
|
||||
const remaining = roundMoney(carriedIn + allocation - spent, baseDigits);
|
||||
|
||||
monthFigures.set(post.id, {
|
||||
carriedIn,
|
||||
|
||||
+14
-7
@@ -2,6 +2,13 @@ import { test, expect, describe } from "bun:test";
|
||||
import { toBase, fromBase, MissingRateError } from "./fx.ts";
|
||||
import type { FxRate } from "./types.ts";
|
||||
|
||||
/** Digits for the currencies these tests use; all real-world 2dp. */
|
||||
const TEST_CURRENCIES = [
|
||||
{ code: "DKK", digits: 2, symbol: "kr" },
|
||||
{ code: "USD", digits: 2, symbol: "$" },
|
||||
{ code: "EUR", digits: 2, symbol: "\u20ac" },
|
||||
];
|
||||
|
||||
const rates: FxRate[] = [
|
||||
{ currency: "EUR", baseUnitsPerOne: 7.46, updatedAt: "2026-09-01", source: "manual" },
|
||||
{ currency: "USD", baseUnitsPerOne: 6.9, updatedAt: "2026-09-01", source: "manual" },
|
||||
@@ -9,23 +16,23 @@ const rates: FxRate[] = [
|
||||
|
||||
describe("toBase", () => {
|
||||
test("passes base-currency values through untouched", () => {
|
||||
expect(toBase({ amount: 100, currency: "DKK" }, "DKK", rates)).toBe(100);
|
||||
expect(toBase({ amount: 100, currency: "DKK" }, "DKK", rates, TEST_CURRENCIES)).toBe(100);
|
||||
});
|
||||
|
||||
test("converts using baseUnitsPerOne", () => {
|
||||
expect(toBase({ amount: 10, currency: "EUR" }, "DKK", rates)).toBe(74.6);
|
||||
expect(toBase({ amount: 10, currency: "EUR" }, "DKK", rates, TEST_CURRENCIES)).toBe(74.6);
|
||||
});
|
||||
|
||||
test("rounds the conversion result", () => {
|
||||
expect(toBase({ amount: 3.33, currency: "USD" }, "DKK", rates)).toBe(22.98);
|
||||
expect(toBase({ amount: 3.33, currency: "USD" }, "DKK", rates, TEST_CURRENCIES)).toBe(22.98);
|
||||
});
|
||||
|
||||
test("throws MissingRateError naming the currency", () => {
|
||||
expect(() => toBase({ amount: 1, currency: "USD" }, "DKK", [])).toThrow(
|
||||
expect(() => toBase({ amount: 1, currency: "USD" }, "DKK", [], TEST_CURRENCIES)).toThrow(
|
||||
MissingRateError,
|
||||
);
|
||||
try {
|
||||
toBase({ amount: 1, currency: "USD" }, "DKK", []);
|
||||
toBase({ amount: 1, currency: "USD" }, "DKK", [], TEST_CURRENCIES);
|
||||
} catch (error) {
|
||||
expect((error as MissingRateError).currency).toBe("USD");
|
||||
}
|
||||
@@ -34,10 +41,10 @@ describe("toBase", () => {
|
||||
|
||||
describe("fromBase", () => {
|
||||
test("passes base through untouched", () => {
|
||||
expect(fromBase(100, "DKK", "DKK", rates)).toBe(100);
|
||||
expect(fromBase(100, "DKK", "DKK", rates, TEST_CURRENCIES)).toBe(100);
|
||||
});
|
||||
|
||||
test("inverts the conversion", () => {
|
||||
expect(fromBase(74.6, "EUR", "DKK", rates)).toBe(10);
|
||||
expect(fromBase(74.6, "EUR", "DKK", rates, TEST_CURRENCIES)).toBe(10);
|
||||
});
|
||||
});
|
||||
+10
-5
@@ -1,5 +1,6 @@
|
||||
import { roundMoney } from "./money.ts";
|
||||
import type { Currency, FxRate, Money } from "./types.ts";
|
||||
import { digitsFor } from "./currencies.ts";
|
||||
import type { Currency, CurrencyDef, FxRate, Money } from "./types.ts";
|
||||
|
||||
export class MissingRateError extends Error {
|
||||
constructor(public readonly currency: Currency) {
|
||||
@@ -19,11 +20,13 @@ export function toBase(
|
||||
value: Money,
|
||||
baseCurrency: Currency,
|
||||
rates: FxRate[],
|
||||
currencies: readonly CurrencyDef[],
|
||||
): number {
|
||||
const digits = digitsFor(currencies, baseCurrency);
|
||||
if (value.currency === baseCurrency) {
|
||||
return roundMoney(value.amount, baseCurrency);
|
||||
return roundMoney(value.amount, digits);
|
||||
}
|
||||
return roundMoney(value.amount * rateFor(value.currency, rates), baseCurrency);
|
||||
return roundMoney(value.amount * rateFor(value.currency, rates), digits);
|
||||
}
|
||||
|
||||
/** Converts a base-currency amount into `target`, rounded. */
|
||||
@@ -32,7 +35,9 @@ export function fromBase(
|
||||
target: Currency,
|
||||
baseCurrency: Currency,
|
||||
rates: FxRate[],
|
||||
currencies: readonly CurrencyDef[],
|
||||
): number {
|
||||
if (target === baseCurrency) return roundMoney(amount, target);
|
||||
return roundMoney(amount / rateFor(target, rates), target);
|
||||
const digits = digitsFor(currencies, target);
|
||||
if (target === baseCurrency) return roundMoney(amount, digits);
|
||||
return roundMoney(amount / rateFor(target, rates), digits);
|
||||
}
|
||||
+17
-17
@@ -7,62 +7,62 @@ import {
|
||||
|
||||
describe("roundMoney", () => {
|
||||
test("rounds to 2 decimal places", () => {
|
||||
expect(roundMoney(1.234)).toBe(1.23);
|
||||
expect(roundMoney(1.235)).toBe(1.24);
|
||||
expect(roundMoney(0.1 + 0.2)).toBe(0.3);
|
||||
expect(roundMoney(1.234, 2)).toBe(1.23);
|
||||
expect(roundMoney(1.235, 2)).toBe(1.24);
|
||||
expect(roundMoney(0.1 + 0.2, 2)).toBe(0.3);
|
||||
});
|
||||
|
||||
test("handles negatives symmetrically", () => {
|
||||
expect(roundMoney(-1.235)).toBe(-1.24);
|
||||
expect(roundMoney(-1.235, 2)).toBe(-1.24);
|
||||
});
|
||||
});
|
||||
|
||||
describe("distributeByWeight", () => {
|
||||
test("splits proportionally", () => {
|
||||
expect(distributeByWeight(200, [60, 40], 0)).toEqual([120, 80]);
|
||||
expect(distributeByWeight(200, [60, 40], 0, 2)).toEqual([120, 80]);
|
||||
});
|
||||
|
||||
test("thirds sum exactly to the total", () => {
|
||||
const parts = distributeByWeight(100, [1, 1, 1], 2);
|
||||
const parts = distributeByWeight(100, [1, 1, 1], 2, 2);
|
||||
expect(parts).toEqual([33.33, 33.33, 33.34]);
|
||||
expect(roundMoney(parts[0]! + parts[1]! + parts[2]!)).toBe(100);
|
||||
expect(roundMoney(parts[0]! + parts[1]! + parts[2]!, 2)).toBe(100);
|
||||
});
|
||||
|
||||
test("the remainder index absorbs the rounding drift", () => {
|
||||
const parts = distributeByWeight(100, [1, 1, 1], 0);
|
||||
const parts = distributeByWeight(100, [1, 1, 1], 0, 2);
|
||||
expect(parts).toEqual([33.34, 33.33, 33.33]);
|
||||
});
|
||||
|
||||
test("a single split takes the whole total", () => {
|
||||
expect(distributeByWeight(99.99, [100], 0)).toEqual([99.99]);
|
||||
expect(distributeByWeight(99.99, [100], 0, 2)).toEqual([99.99]);
|
||||
});
|
||||
|
||||
test("weights that do not sum to 100 are still proportional", () => {
|
||||
expect(distributeByWeight(100, [1, 3], 1)).toEqual([25, 75]);
|
||||
expect(distributeByWeight(100, [1, 3], 1, 2)).toEqual([25, 75]);
|
||||
});
|
||||
|
||||
test("all-zero weights put everything on the remainder rather than throwing", () => {
|
||||
// Reachable from a zero-value purchase; must not divide by zero.
|
||||
expect(distributeByWeight(100, [0, 0], 1)).toEqual([0, 100]);
|
||||
expect(distributeByWeight(0, [0, 0], 0)).toEqual([0, 0]);
|
||||
expect(distributeByWeight(100, [0, 0], 1, 2)).toEqual([0, 100]);
|
||||
expect(distributeByWeight(0, [0, 0], 0, 2)).toEqual([0, 0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("distributeByAmount", () => {
|
||||
test("uses the given amounts as-is", () => {
|
||||
expect(distributeByAmount(200, [120, 80], 1)).toEqual([120, 80]);
|
||||
expect(distributeByAmount(200, [120, 80], 1, 2)).toEqual([120, 80]);
|
||||
});
|
||||
|
||||
test("the remainder index absorbs a shortfall", () => {
|
||||
expect(distributeByAmount(200, [120, 50], 1)).toEqual([120, 80]);
|
||||
expect(distributeByAmount(200, [120, 50], 1, 2)).toEqual([120, 80]);
|
||||
});
|
||||
|
||||
test("the remainder index absorbs an excess, even going negative", () => {
|
||||
expect(distributeByAmount(100, [120, 50], 1)).toEqual([120, -20]);
|
||||
expect(distributeByAmount(100, [120, 50], 1, 2)).toEqual([120, -20]);
|
||||
});
|
||||
|
||||
test("parts always sum exactly to the total", () => {
|
||||
const parts = distributeByAmount(10, [3.33, 3.33, 0], 2);
|
||||
expect(roundMoney(parts.reduce((a, b) => a + b, 0))).toBe(10);
|
||||
const parts = distributeByAmount(10, [3.33, 3.33, 0], 2, 2);
|
||||
expect(roundMoney(parts.reduce((a, b) => a + b, 0), 2)).toBe(10);
|
||||
});
|
||||
});
|
||||
+22
-14
@@ -1,13 +1,21 @@
|
||||
import { CURRENCY_DIGITS, type Currency } from "./types.ts";
|
||||
/**
|
||||
* Money is a float. Two rules make that safe, and both are about WHERE
|
||||
* rounding happens rather than how.
|
||||
*
|
||||
* Digits are passed in, never looked up from a table here. They live in the
|
||||
* dataset because the owner can define currencies, so every call site has to
|
||||
* resolve them with `digitsFor` — see src/domain/currencies.ts. That is
|
||||
* deliberate friction: a default of 2 would silently mis-round every
|
||||
* zero-decimal currency and no existing test would notice.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Rounds a float to its currency's minor unit. `toFixed` rounds the double's
|
||||
* actual decimal value, which is the most predictable option available for
|
||||
* binary floats. Call this after every division and FX conversion, and before
|
||||
* Rounds to a currency's minor unit. `toFixed` rounds the double's actual
|
||||
* decimal value, which is the most predictable option available for binary
|
||||
* floats. Call this after every division and FX conversion, and before
|
||||
* persisting.
|
||||
*/
|
||||
export function roundMoney(amount: number, currency: Currency = "DKK"): number {
|
||||
const digits = CURRENCY_DIGITS[currency];
|
||||
export function roundMoney(amount: number, digits: number): number {
|
||||
return Number(amount.toFixed(digits));
|
||||
}
|
||||
|
||||
@@ -15,7 +23,7 @@ function withRemainder(
|
||||
total: number,
|
||||
parts: number[],
|
||||
remainderIndex: number,
|
||||
currency: Currency,
|
||||
digits: number,
|
||||
): number[] {
|
||||
const result = [...parts];
|
||||
let othersSum = 0;
|
||||
@@ -24,7 +32,7 @@ function withRemainder(
|
||||
}
|
||||
// Never round this independently — subtraction is what makes the parts sum
|
||||
// exactly to the total.
|
||||
result[remainderIndex] = roundMoney(total - othersSum, currency);
|
||||
result[remainderIndex] = roundMoney(total - othersSum, digits);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -33,7 +41,7 @@ export function distributeByWeight(
|
||||
total: number,
|
||||
weights: number[],
|
||||
remainderIndex: number,
|
||||
currency: Currency = "DKK",
|
||||
digits: number,
|
||||
): number[] {
|
||||
const weightSum = weights.reduce((a, b) => a + b, 0);
|
||||
// All-zero weights would divide by zero. A zero-total purchase is legal (and
|
||||
@@ -42,9 +50,9 @@ export function distributeByWeight(
|
||||
const parts = weights.map((w, i) =>
|
||||
i === remainderIndex || weightSum === 0
|
||||
? 0
|
||||
: roundMoney((total * w) / weightSum, currency),
|
||||
: roundMoney((total * w) / weightSum, digits),
|
||||
);
|
||||
return withRemainder(total, parts, remainderIndex, currency);
|
||||
return withRemainder(total, parts, remainderIndex, digits);
|
||||
}
|
||||
|
||||
/** Uses `amounts` as-is; `remainderIndex` absorbs any shortfall or excess. */
|
||||
@@ -52,10 +60,10 @@ export function distributeByAmount(
|
||||
total: number,
|
||||
amounts: number[],
|
||||
remainderIndex: number,
|
||||
currency: Currency = "DKK",
|
||||
digits: number,
|
||||
): number[] {
|
||||
const parts = amounts.map((a, i) =>
|
||||
i === remainderIndex ? 0 : roundMoney(a, currency),
|
||||
i === remainderIndex ? 0 : roundMoney(a, digits),
|
||||
);
|
||||
return withRemainder(total, parts, remainderIndex, currency);
|
||||
return withRemainder(total, parts, remainderIndex, digits);
|
||||
}
|
||||
@@ -1,36 +1,52 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { equalSlices, sliceTotal, slicesBalance } from "./plans.ts";
|
||||
|
||||
/**
|
||||
* Digits are passed in rather than looked up from a table, so a currency
|
||||
* with a different minor unit is expressed directly instead of by
|
||||
* monkey-patching a constant, which is what this file used to have to do.
|
||||
*/
|
||||
const DKK = 2;
|
||||
|
||||
test("equal slices divide the total across consecutive months", () => {
|
||||
const slices = equalSlices({ amount: 3000, currency: "DKK" }, "2026-10", 6);
|
||||
const slices = equalSlices({ amount: 3000, currency: "DKK" }, "2026-10", 6, DKK);
|
||||
expect(slices).toHaveLength(6);
|
||||
expect(slices[0]).toEqual({ month: "2026-10", amount: { amount: 500, currency: "DKK" } });
|
||||
expect(slices[5]!.month).toBe("2027-03");
|
||||
});
|
||||
|
||||
test("uneven totals still sum exactly, the last slice absorbing the drift", () => {
|
||||
const slices = equalSlices({ amount: 100, currency: "DKK" }, "2026-01", 3);
|
||||
const slices = equalSlices({ amount: 100, currency: "DKK" }, "2026-01", 3, DKK);
|
||||
expect(slices.map((s) => s.amount.amount)).toEqual([33.33, 33.33, 33.34]);
|
||||
expect(sliceTotal(slices)).toBe(100);
|
||||
expect(sliceTotal(slices, DKK)).toBe(100);
|
||||
});
|
||||
|
||||
test("a single slice takes the whole total", () => {
|
||||
const slices = equalSlices({ amount: 99.99, currency: "DKK" }, "2026-01", 1);
|
||||
const slices = equalSlices({ amount: 99.99, currency: "DKK" }, "2026-01", 1, DKK);
|
||||
expect(slices).toEqual([
|
||||
{ month: "2026-01", amount: { amount: 99.99, currency: "DKK" } },
|
||||
]);
|
||||
});
|
||||
|
||||
test("a count below one is rejected", () => {
|
||||
expect(() => equalSlices({ amount: 100, currency: "DKK" }, "2026-01", 0)).toThrow(
|
||||
expect(() => equalSlices({ amount: 100, currency: "DKK" }, "2026-01", 0, DKK)).toThrow(
|
||||
/at least one month/i,
|
||||
);
|
||||
});
|
||||
|
||||
test("slicesBalance reports the shortfall the editor must show", () => {
|
||||
const slices = equalSlices({ amount: 100, currency: "DKK" }, "2026-01", 2);
|
||||
expect(slicesBalance({ amount: 100, currency: "DKK" }, slices)).toBe(0);
|
||||
const slices = equalSlices({ amount: 100, currency: "DKK" }, "2026-01", 2, DKK);
|
||||
expect(slicesBalance({ amount: 100, currency: "DKK" }, slices, DKK)).toBe(0);
|
||||
|
||||
slices[0]!.amount.amount = 30;
|
||||
expect(slicesBalance({ amount: 100, currency: "DKK" }, slices)).toBe(20);
|
||||
expect(slicesBalance({ amount: 100, currency: "DKK" }, slices, DKK)).toBe(20);
|
||||
});
|
||||
|
||||
test("sliceTotal rounds to the digits it is given, not always to 2 places", () => {
|
||||
const slices = [
|
||||
{ month: "2026-01", amount: { amount: 10.4, currency: "EUR" } },
|
||||
{ month: "2026-02", amount: { amount: 10.4, currency: "EUR" } },
|
||||
];
|
||||
expect(sliceTotal(slices, 0)).toBe(21);
|
||||
expect(sliceTotal(slices, 2)).toBe(20.8);
|
||||
});
|
||||
+16
-10
@@ -7,28 +7,34 @@ export function equalSlices(
|
||||
total: Money,
|
||||
startMonth: MonthId,
|
||||
count: number,
|
||||
digits: number,
|
||||
): ScheduleSlice[] {
|
||||
if (!Number.isInteger(count) || count < 1) {
|
||||
throw new Error("A finance plan needs at least one month");
|
||||
}
|
||||
const weights = new Array(count).fill(1);
|
||||
const amounts = distributeByWeight(
|
||||
total.amount,
|
||||
weights,
|
||||
count - 1,
|
||||
total.currency,
|
||||
);
|
||||
const amounts = distributeByWeight(total.amount, weights, count - 1, digits);
|
||||
return amounts.map((amount, i) => ({
|
||||
month: addMonths(startMonth, i),
|
||||
amount: { amount, currency: total.currency },
|
||||
}));
|
||||
}
|
||||
|
||||
export function sliceTotal(slices: ScheduleSlice[]): number {
|
||||
return roundMoney(slices.reduce((sum, s) => sum + s.amount.amount, 0));
|
||||
/**
|
||||
* Every slice of a schedule carries the purchase's currency, so the first one
|
||||
* names the currency to round in. Rounding to a hardcoded 2 places would be
|
||||
* wrong for any currency whose minor unit is not hundredths.
|
||||
*/
|
||||
export function sliceTotal(slices: ScheduleSlice[], digits: number): number {
|
||||
const sum = slices.reduce((total, s) => total + s.amount.amount, 0);
|
||||
return roundMoney(sum, digits);
|
||||
}
|
||||
|
||||
/** total - sum(slices). Positive means the plan is short of the total. */
|
||||
export function slicesBalance(total: Money, slices: ScheduleSlice[]): number {
|
||||
return roundMoney(total.amount - sliceTotal(slices), total.currency);
|
||||
export function slicesBalance(
|
||||
total: Money,
|
||||
slices: ScheduleSlice[],
|
||||
digits: number,
|
||||
): number {
|
||||
return roundMoney(total.amount - sliceTotal(slices, digits), digits);
|
||||
}
|
||||
+16
-6
@@ -1,6 +1,7 @@
|
||||
import type { Dataset, MonthId, Post } from "./types.ts";
|
||||
import { SEED_CURRENCIES } from "./types.ts";
|
||||
import type { CurrencyDef, Dataset, FxRate, MonthId, Post } from "./types.ts";
|
||||
|
||||
export const SCHEMA_VERSION = 2;
|
||||
export const SCHEMA_VERSION = 3;
|
||||
|
||||
const SEED_POST_NAMES = ["Video Games", "Food", "Events and Social"] as const;
|
||||
|
||||
@@ -9,10 +10,16 @@ export function newId(): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* The first-run dataset. Takes the start month as an argument because the
|
||||
* domain layer must not read the ambient clock.
|
||||
* The first-run dataset. Takes the start month AND the seed exchange rates as
|
||||
* arguments because the domain layer must read neither the ambient clock nor
|
||||
* the ambient environment. The store passes `BAKED_FX_RATES`; callers that
|
||||
* want a bare dataset pass nothing.
|
||||
*/
|
||||
export function createSeedDataset(startMonth: MonthId): Dataset {
|
||||
export function createSeedDataset(
|
||||
startMonth: MonthId,
|
||||
fxRates: readonly FxRate[] = [],
|
||||
currencies: readonly CurrencyDef[] = SEED_CURRENCIES,
|
||||
): Dataset {
|
||||
const posts: Post[] = SEED_POST_NAMES.map((name, order) => ({
|
||||
id: newId(),
|
||||
name,
|
||||
@@ -30,7 +37,10 @@ export function createSeedDataset(startMonth: MonthId): Dataset {
|
||||
foldStartMonth: startMonth,
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
},
|
||||
fxRates: [],
|
||||
// Copied, so a caller passing shared constants cannot have them edited
|
||||
// out from under it the first time the user changes a rate or a currency.
|
||||
currencies: currencies.map((currency) => ({ ...currency })),
|
||||
fxRates: fxRates.map((rate) => ({ ...rate })),
|
||||
posts,
|
||||
months: [
|
||||
{ id: startMonth, income: { amount: 0, currency: "DKK" }, ruleOverrides: {} },
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { CURRENCIES, CURRENCY_DIGITS } from "./types.ts";
|
||||
import { DEFAULT_CURRENCY_DIGITS, SEED_CURRENCIES } from "./types.ts";
|
||||
|
||||
test("supported currencies are exactly DKK, USD, EUR", () => {
|
||||
expect(CURRENCIES).toEqual(["DKK", "USD", "EUR"]);
|
||||
/**
|
||||
* This file used to assert "the supported currencies are exactly DKK, USD,
|
||||
* EUR". That is no longer a property of the code: the owner defines
|
||||
* currencies, so the dataset is the authority and there is nothing global to
|
||||
* assert. What remains true is what a NEW dataset starts with.
|
||||
*/
|
||||
test("a new dataset starts with the three currencies the app shipped with", () => {
|
||||
expect(SEED_CURRENCIES.map((c) => c.code)).toEqual(["DKK", "USD", "EUR"]);
|
||||
});
|
||||
|
||||
test("every supported currency has 2 decimal digits", () => {
|
||||
for (const c of CURRENCIES) {
|
||||
expect(CURRENCY_DIGITS[c]).toBe(2);
|
||||
test("each seeded currency carries its decimals and a symbol to type", () => {
|
||||
for (const currency of SEED_CURRENCIES) {
|
||||
expect(currency.digits).toBe(2);
|
||||
expect(currency.symbol).toBeTruthy();
|
||||
expect(currency.name).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
test("the fallback for an undefined currency is two places", () => {
|
||||
expect(DEFAULT_CURRENCY_DIGITS).toBe(2);
|
||||
});
|
||||
+39
-7
@@ -1,11 +1,37 @@
|
||||
export const CURRENCIES = ["DKK", "USD", "EUR"] as const;
|
||||
export type Currency = (typeof CURRENCIES)[number];
|
||||
/**
|
||||
* A currency CODE, uppercase, normally ISO 4217 ("DKK", "JPY"). Open rather
|
||||
* than a closed union because the owner can define their own currencies:
|
||||
* a union would make that a code change.
|
||||
*
|
||||
* The safety a union gave up is replaced by validation at the boundaries —
|
||||
* `parseDatasetJson` rejects a code no currency defines, and the UI only ever
|
||||
* offers codes from the dataset's own table.
|
||||
*/
|
||||
export type Currency = string;
|
||||
|
||||
export const CURRENCY_DIGITS: Record<Currency, number> = {
|
||||
DKK: 2,
|
||||
USD: 2,
|
||||
EUR: 2,
|
||||
};
|
||||
/** What the owner has told the app about one currency. */
|
||||
export interface CurrencyDef {
|
||||
code: Currency;
|
||||
/** Decimal places in its minor unit: 2 for DKK, 0 for JPY, 3 for KWD. */
|
||||
digits: number;
|
||||
/** "kr", "$", "€". Optional — a currency can be typed by code alone. */
|
||||
symbol?: string;
|
||||
/** "Danish krone". Shown in Settings; never used for identity. */
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The currencies a brand-new dataset starts with. NOT the set of valid
|
||||
* currencies — that lives in the dataset, because the owner can add more.
|
||||
*/
|
||||
export const SEED_CURRENCIES: readonly CurrencyDef[] = [
|
||||
{ code: "DKK", digits: 2, symbol: "kr", name: "Danish krone" },
|
||||
{ code: "USD", digits: 2, symbol: "$", name: "US dollar" },
|
||||
{ code: "EUR", digits: 2, symbol: "\u20ac", name: "Euro" },
|
||||
];
|
||||
|
||||
/** Used when a currency has no definition. Two places is right almost always. */
|
||||
export const DEFAULT_CURRENCY_DIGITS = 2;
|
||||
|
||||
/** A float amount paired with its currency. See CLAUDE.md for rounding rules. */
|
||||
export interface Money {
|
||||
@@ -106,6 +132,12 @@ export interface Settings {
|
||||
|
||||
export interface Dataset {
|
||||
settings: Settings;
|
||||
/**
|
||||
* Every currency this dataset knows, including the base. The owner can add
|
||||
* to it, so it — not a compile-time union — is the authority on what codes
|
||||
* are valid, what their symbols are, and how many decimals they round to.
|
||||
*/
|
||||
currencies: CurrencyDef[];
|
||||
fxRates: FxRate[];
|
||||
posts: Post[];
|
||||
months: Month[];
|
||||
|
||||
@@ -2,6 +2,13 @@ import { test, expect, describe } from "bun:test";
|
||||
import { monthView, yearView, summaryView, datasetMonthSpan } from "./views.ts";
|
||||
import type { Dataset, Post, Purchase } from "./types.ts";
|
||||
|
||||
/** Digits for the currencies these tests use; all real-world 2dp. */
|
||||
const TEST_CURRENCIES = [
|
||||
{ code: "DKK", digits: 2, symbol: "kr" },
|
||||
{ code: "USD", digits: 2, symbol: "$" },
|
||||
{ code: "EUR", digits: 2, symbol: "\u20ac" },
|
||||
];
|
||||
|
||||
function post(id: string, order: number, percent: number, archived = false): Post {
|
||||
return {
|
||||
id,
|
||||
@@ -27,6 +34,7 @@ function spend(id: string, postId: string, amount: number, date: string): Purcha
|
||||
|
||||
const data: Dataset = {
|
||||
settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 1 },
|
||||
currencies: TEST_CURRENCIES,
|
||||
fxRates: [],
|
||||
posts: [post("food", 0, 20), post("games", 1, 10)],
|
||||
months: [
|
||||
|
||||
+15
-11
@@ -1,4 +1,5 @@
|
||||
import { incomeFor, isOverridden } from "./allocation.ts";
|
||||
import { digitsFor } from "./currencies.ts";
|
||||
import { figuresFor, foldBalances, type Fold, type PostMonthFigures } from "./fold.ts";
|
||||
import { roundMoney } from "./money.ts";
|
||||
import { compareMonths, monthOf, monthRange, monthsOfYear } from "./months.ts";
|
||||
@@ -44,13 +45,14 @@ export function monthView(dataset: Dataset, monthId: MonthId): MonthViewModel {
|
||||
}
|
||||
|
||||
const base = dataset.settings.baseCurrency;
|
||||
const baseDigits = digitsFor(dataset.currencies, base);
|
||||
const totalAllocation = roundMoney(
|
||||
rows.reduce((sum, r) => sum + r.figures.allocation, 0),
|
||||
base,
|
||||
baseDigits,
|
||||
);
|
||||
const totalCharges = roundMoney(
|
||||
rows.reduce((sum, r) => sum + r.figures.charges, 0),
|
||||
base,
|
||||
baseDigits,
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -58,7 +60,7 @@ export function monthView(dataset: Dataset, monthId: MonthId): MonthViewModel {
|
||||
income,
|
||||
totalAllocation,
|
||||
totalCharges,
|
||||
unallocated: roundMoney(income - totalAllocation, base),
|
||||
unallocated: roundMoney(income - totalAllocation, baseDigits),
|
||||
rows,
|
||||
};
|
||||
}
|
||||
@@ -86,6 +88,7 @@ export function yearView(dataset: Dataset, year: number): YearViewModel {
|
||||
const months = monthsOfYear(year);
|
||||
const fold = foldBalances(dataset, months[11]!);
|
||||
const base = dataset.settings.baseCurrency;
|
||||
const baseDigits = digitsFor(dataset.currencies, base);
|
||||
|
||||
const incomeByMonth = months.map((m) => incomeFor(dataset, m));
|
||||
|
||||
@@ -96,11 +99,11 @@ export function yearView(dataset: Dataset, year: number): YearViewModel {
|
||||
byMonth,
|
||||
totalAllocation: roundMoney(
|
||||
byMonth.reduce((sum, f) => sum + f.allocation, 0),
|
||||
base,
|
||||
baseDigits,
|
||||
),
|
||||
totalCharges: roundMoney(
|
||||
byMonth.reduce((sum, f) => sum + f.charges, 0),
|
||||
base,
|
||||
baseDigits,
|
||||
),
|
||||
closingBalance: byMonth[11]!.remaining,
|
||||
};
|
||||
@@ -112,11 +115,11 @@ export function yearView(dataset: Dataset, year: number): YearViewModel {
|
||||
incomeByMonth,
|
||||
totalIncome: roundMoney(
|
||||
incomeByMonth.reduce((a, b) => a + b, 0),
|
||||
base,
|
||||
baseDigits,
|
||||
),
|
||||
totalCharges: roundMoney(
|
||||
rows.reduce((sum, r) => sum + r.totalCharges, 0),
|
||||
base,
|
||||
baseDigits,
|
||||
),
|
||||
rows,
|
||||
};
|
||||
@@ -138,6 +141,7 @@ export function summaryView(
|
||||
): SummaryViewModel {
|
||||
const months = monthRange(from, to);
|
||||
const base = dataset.settings.baseCurrency;
|
||||
const baseDigits = digitsFor(dataset.currencies, base);
|
||||
const fold: Fold =
|
||||
months.length > 0 ? foldBalances(dataset, months[months.length - 1]!) : new Map();
|
||||
|
||||
@@ -145,7 +149,7 @@ export function summaryView(
|
||||
post,
|
||||
charges: roundMoney(
|
||||
months.reduce((sum, m) => sum + figuresFor(fold, post.id, m).charges, 0),
|
||||
base,
|
||||
baseDigits,
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -156,7 +160,7 @@ export function summaryView(
|
||||
(sum, p) => sum + figuresFor(fold, p.id, monthId).charges,
|
||||
0,
|
||||
),
|
||||
base,
|
||||
baseDigits,
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -167,11 +171,11 @@ export function summaryView(
|
||||
byMonth,
|
||||
totalCharges: roundMoney(
|
||||
byMonth.reduce((sum, m) => sum + m.charges, 0),
|
||||
base,
|
||||
baseDigits,
|
||||
),
|
||||
totalIncome: roundMoney(
|
||||
months.reduce((sum, m) => sum + incomeFor(dataset, m), 0),
|
||||
base,
|
||||
baseDigits,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
+82
-11
@@ -1,4 +1,4 @@
|
||||
import { CURRENCIES, type Currency, type Dataset, type MonthId } from "../domain/types.ts";
|
||||
import type { Currency, CurrencyDef, Dataset, MonthId } from "../domain/types.ts";
|
||||
import { migrate } from "../store/migrations.ts";
|
||||
|
||||
export class ImportValidationError extends Error {
|
||||
@@ -36,11 +36,80 @@ const MONTH_ID = /^\d{4}-(0[1-9]|1[0-2])$/;
|
||||
*/
|
||||
const PURCHASE_DATE = /^\d{4}-(0[1-9]|1[0-2])(-(0[1-9]|[12]\d|3[01]))?$/;
|
||||
|
||||
function requireCurrency(value: unknown, where: string): Currency {
|
||||
if (!CURRENCIES.includes(value as Currency)) {
|
||||
throw new ImportValidationError(`Unsupported currency ${String(value)} in ${where}`);
|
||||
/**
|
||||
* `Currency` is an open string now, so this — not the type system — is what
|
||||
* stops a code the dataset never defined reaching the fold, where it would
|
||||
* throw MissingRateError or round at a guessed number of decimals.
|
||||
*/
|
||||
function requireCurrency(
|
||||
value: unknown,
|
||||
where: string,
|
||||
defined: ReadonlySet<Currency>,
|
||||
): Currency {
|
||||
if (typeof value !== "string" || !defined.has(value)) {
|
||||
throw new ImportValidationError(
|
||||
`Unsupported currency ${String(value)} in ${where}. Define it under Settings first.`,
|
||||
);
|
||||
}
|
||||
return value as Currency;
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Codes are identity: they key the FX table and every stored Money. */
|
||||
const CURRENCY_CODE = /^[A-Z]{2,8}$/;
|
||||
|
||||
/**
|
||||
* Validates the currency table itself, before anything is checked against it.
|
||||
* A table with a duplicate code would make `digitsFor` resolve arbitrarily,
|
||||
* and a non-integer or absurd `digits` would round money to nonsense.
|
||||
*/
|
||||
function requireCurrencyTable(data: Record<string, unknown>): CurrencyDef[] {
|
||||
const raw = data.currencies;
|
||||
if (!Array.isArray(raw)) {
|
||||
throw new ImportValidationError('Dataset field "currencies" is missing or not an array');
|
||||
}
|
||||
if (raw.length === 0) {
|
||||
throw new ImportValidationError("A dataset must define at least one currency");
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const table: CurrencyDef[] = [];
|
||||
|
||||
for (const entry of raw as Array<Record<string, unknown>>) {
|
||||
const code = entry?.code;
|
||||
if (typeof code !== "string" || !CURRENCY_CODE.test(code)) {
|
||||
throw new ImportValidationError(
|
||||
`Currency code ${JSON.stringify(code)} is not 2-8 uppercase letters`,
|
||||
);
|
||||
}
|
||||
if (seen.has(code)) {
|
||||
throw new ImportValidationError(
|
||||
`Currency "${code}" is defined twice; which decimals apply would be arbitrary`,
|
||||
);
|
||||
}
|
||||
seen.add(code);
|
||||
|
||||
const digits = entry.digits;
|
||||
if (typeof digits !== "number" || !Number.isInteger(digits) || digits < 0 || digits > 4) {
|
||||
throw new ImportValidationError(
|
||||
`Currency "${code}" has invalid decimal places ${String(digits)}; expected an integer 0-4`,
|
||||
);
|
||||
}
|
||||
if (entry.symbol !== undefined && typeof entry.symbol !== "string") {
|
||||
throw new ImportValidationError(`Currency "${code}" has a non-string symbol`);
|
||||
}
|
||||
if (entry.name !== undefined && typeof entry.name !== "string") {
|
||||
throw new ImportValidationError(`Currency "${code}" has a non-string name`);
|
||||
}
|
||||
|
||||
table.push({
|
||||
code,
|
||||
digits,
|
||||
...(typeof entry.symbol === "string" ? { symbol: entry.symbol } : {}),
|
||||
...(typeof entry.name === "string" ? { name: entry.name } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
return table;
|
||||
}
|
||||
|
||||
function requireArray(data: Record<string, unknown>, key: string): unknown[] {
|
||||
@@ -75,12 +144,14 @@ export function parseDatasetJson(text: string): Dataset {
|
||||
}
|
||||
|
||||
const data = dataset as unknown as Record<string, unknown>;
|
||||
const currencies = requireCurrencyTable(data);
|
||||
const defined = new Set(currencies.map((currency) => currency.code));
|
||||
requireArray(data, "posts");
|
||||
requireArray(data, "months");
|
||||
requireArray(data, "purchases");
|
||||
requireArray(data, "fxRates");
|
||||
|
||||
requireCurrency(dataset.settings.baseCurrency, "settings.baseCurrency");
|
||||
requireCurrency(dataset.settings.baseCurrency, "settings.baseCurrency", defined);
|
||||
if (!MONTH_ID.test(dataset.settings.foldStartMonth)) {
|
||||
throw new ImportValidationError(
|
||||
`Invalid foldStartMonth "${dataset.settings.foldStartMonth}"`,
|
||||
@@ -89,7 +160,7 @@ export function parseDatasetJson(text: string): Dataset {
|
||||
|
||||
const postIds = new Set(dataset.posts.map((p) => p.id));
|
||||
for (const post of dataset.posts) {
|
||||
requireCurrency(post.currency, `post "${post.name}"`);
|
||||
requireCurrency(post.currency, `post "${post.name}"`, defined);
|
||||
|
||||
const rules = (post as unknown as Record<string, unknown>).rules;
|
||||
if (!Array.isArray(rules)) {
|
||||
@@ -120,7 +191,7 @@ export function parseDatasetJson(text: string): Dataset {
|
||||
amount?: { currency?: unknown };
|
||||
};
|
||||
if (rule.kind === "fixed") {
|
||||
requireCurrency(rule.amount?.currency, `post "${post.name}" rule from ${from}`);
|
||||
requireCurrency(rule.amount?.currency, `post "${post.name}" rule from ${from}`, defined);
|
||||
} else if (rule.kind !== "percentOfIncome") {
|
||||
throw new ImportValidationError(
|
||||
`Post "${post.name}" has a rule from "${from}" of unknown kind "${String(rule.kind)}"`,
|
||||
@@ -133,12 +204,12 @@ export function parseDatasetJson(text: string): Dataset {
|
||||
if (!MONTH_ID.test(month.id)) {
|
||||
throw new ImportValidationError(`Invalid month id "${month.id}"`);
|
||||
}
|
||||
requireCurrency(month.income.currency, `month ${month.id} income`);
|
||||
requireCurrency(month.income.currency, `month ${month.id} income`, defined);
|
||||
}
|
||||
|
||||
for (const purchase of dataset.purchases) {
|
||||
const label = `purchase "${purchase.description}"`;
|
||||
requireCurrency(purchase.total.currency, label);
|
||||
requireCurrency(purchase.total.currency, label, defined);
|
||||
|
||||
if (!PURCHASE_DATE.test(purchase.date)) {
|
||||
throw new ImportValidationError(`${label} has an invalid date "${purchase.date}"`);
|
||||
@@ -163,7 +234,7 @@ export function parseDatasetJson(text: string): Dataset {
|
||||
if (!MONTH_ID.test(slice.month)) {
|
||||
throw new ImportValidationError(`${label} has invalid slice month "${slice.month}"`);
|
||||
}
|
||||
requireCurrency(slice.amount.currency, `${label} slice ${slice.month}`);
|
||||
requireCurrency(slice.amount.currency, `${label} slice ${slice.month}`, defined);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+38
-13
@@ -2,7 +2,10 @@ import { zipSync, strToU8 } from "fflate";
|
||||
import { chargesForPurchaseInMonth, sliceAmountForMonth } from "../domain/charges.ts";
|
||||
import { monthRange } from "../domain/months.ts";
|
||||
import { roundMoney } from "../domain/money.ts";
|
||||
import { digitsFor } from "../domain/currencies.ts";
|
||||
import { datasetMonthSpan, monthView, summaryView } from "../domain/views.ts";
|
||||
import type { MonthViewModel } from "../domain/views.ts";
|
||||
import type { PostMonthFigures } from "../domain/fold.ts";
|
||||
import type { Dataset, MonthId } from "../domain/types.ts";
|
||||
import {
|
||||
buildContentXml,
|
||||
@@ -18,20 +21,18 @@ export function odsFilename(monthId: MonthId): string {
|
||||
return `budget-${monthId}.ods`;
|
||||
}
|
||||
|
||||
function summarySheet(dataset: Dataset, months: MonthId[]): Sheet {
|
||||
function summarySheet(dataset: Dataset, months: MonthId[], byMonth: FiguresByMonth): Sheet {
|
||||
const base = dataset.settings.baseCurrency;
|
||||
const rows = [
|
||||
[text(`Post (${base})`), ...months.map((m) => text(m)), text("Total")],
|
||||
];
|
||||
|
||||
for (const post of [...dataset.posts].sort((a, b) => a.order - b.order)) {
|
||||
const perMonth = months.map(
|
||||
(m) => monthView(dataset, m).rows.find((r) => r.post.id === post.id)?.figures.charges ?? 0,
|
||||
);
|
||||
const perMonth = months.map((m) => byMonth.get(m)?.get(post.id)?.charges ?? 0);
|
||||
rows.push([
|
||||
text(post.name),
|
||||
...perMonth.map(num),
|
||||
num(roundMoney(perMonth.reduce((a, b) => a + b, 0), base)),
|
||||
num(roundMoney(perMonth.reduce((a, b) => a + b, 0), digitsFor(dataset.currencies, base))),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -44,12 +45,12 @@ function summarySheet(dataset: Dataset, months: MonthId[]): Sheet {
|
||||
return { name: "Summary", rows };
|
||||
}
|
||||
|
||||
function monthsSheet(dataset: Dataset, months: MonthId[]): Sheet {
|
||||
function monthsSheet(months: MonthId[], views: Map<MonthId, MonthViewModel>): Sheet {
|
||||
const rows = [
|
||||
[text("Month"), text("Income"), text("Allocated"), text("Spent"), text("Unallocated")],
|
||||
];
|
||||
for (const monthId of months) {
|
||||
const view = monthView(dataset, monthId);
|
||||
const view = views.get(monthId)!;
|
||||
rows.push([
|
||||
text(monthId),
|
||||
num(view.income),
|
||||
@@ -82,7 +83,13 @@ function purchasesSheet(dataset: Dataset, months: MonthId[]): Sheet {
|
||||
for (const purchase of dataset.purchases) {
|
||||
const slice = sliceAmountForMonth(purchase, monthId);
|
||||
if (!slice) continue;
|
||||
const charges = chargesForPurchaseInMonth(purchase, monthId, base, dataset.fxRates);
|
||||
const charges = chargesForPurchaseInMonth(
|
||||
purchase,
|
||||
monthId,
|
||||
base,
|
||||
dataset.fxRates,
|
||||
dataset.currencies,
|
||||
);
|
||||
for (const charge of charges) {
|
||||
rows.push([
|
||||
text(monthId),
|
||||
@@ -100,12 +107,17 @@ function purchasesSheet(dataset: Dataset, months: MonthId[]): Sheet {
|
||||
return { name: "Purchases", rows };
|
||||
}
|
||||
|
||||
function postSheet(dataset: Dataset, postId: string, name: string, months: MonthId[]): Sheet {
|
||||
function postSheet(
|
||||
postId: string,
|
||||
name: string,
|
||||
months: MonthId[],
|
||||
byMonth: FiguresByMonth,
|
||||
): Sheet {
|
||||
const rows = [
|
||||
[text("Month"), text("Carried in"), text("Allocated"), text("Spent"), text("Remaining")],
|
||||
];
|
||||
for (const monthId of months) {
|
||||
const figures = monthView(dataset, monthId).rows.find((r) => r.post.id === postId)?.figures;
|
||||
const figures = byMonth.get(monthId)?.get(postId);
|
||||
rows.push([
|
||||
text(monthId),
|
||||
num(figures?.carriedIn ?? 0),
|
||||
@@ -117,17 +129,30 @@ function postSheet(dataset: Dataset, postId: string, name: string, months: Month
|
||||
return { name, rows };
|
||||
}
|
||||
|
||||
/** Every post's figures for one month, keyed by post id. */
|
||||
type FiguresByMonth = Map<MonthId, Map<string, PostMonthFigures>>;
|
||||
|
||||
export function buildWorkbook(dataset: Dataset): Sheet[] {
|
||||
const { from, to } = datasetMonthSpan(dataset);
|
||||
const months = monthRange(from, to);
|
||||
|
||||
// monthView folds from the start of the dataset, so it is O(months) each
|
||||
// time. Calling it once per (post, month) — as the summary and per-post
|
||||
// sheets each used to — made a full export O(posts x months^2). One pass
|
||||
// here, indexed by post, leaves the whole workbook O(months^2) with the
|
||||
// sheets doing map lookups. Output is unchanged.
|
||||
const views = new Map(months.map((m) => [m, monthView(dataset, m)]));
|
||||
const byMonth: FiguresByMonth = new Map(
|
||||
months.map((m) => [m, new Map(views.get(m)!.rows.map((r) => [r.post.id, r.figures]))]),
|
||||
);
|
||||
|
||||
return [
|
||||
summarySheet(dataset, months),
|
||||
monthsSheet(dataset, months),
|
||||
summarySheet(dataset, months, byMonth),
|
||||
monthsSheet(months, views),
|
||||
purchasesSheet(dataset, months),
|
||||
...[...dataset.posts]
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((post) => postSheet(dataset, post.id, post.name, months)),
|
||||
.map((post) => postSheet(post.id, post.name, months, byMonth)),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { test, expect, describe } from "bun:test";
|
||||
import { FONT_FILES, buildFontCss } from "./fontCss.ts";
|
||||
|
||||
describe("the font stylesheet", () => {
|
||||
test("every url is absolute from the deploy base", () => {
|
||||
const css = buildFontCss("/budget2.0/");
|
||||
const urls = [...css.matchAll(/url\("([^"]+)"\)/g)].map((m) => m[1]);
|
||||
expect(urls).toEqual(FONT_FILES.map((f) => `/budget2.0/fonts/${f}`));
|
||||
});
|
||||
|
||||
test("a root deploy is not a special case", () => {
|
||||
const urls = [...buildFontCss("/").matchAll(/url\("([^"]+)"\)/g)].map((m) => m[1]);
|
||||
expect(urls).toEqual(FONT_FILES.map((f) => `/fonts/${f}`));
|
||||
});
|
||||
|
||||
/**
|
||||
* Relative urls are what this must never emit. The stylesheet is served from
|
||||
* the deploy root but the DOCUMENT can be several segments deep
|
||||
* (/budget2.0/month/2026-09), and a relative font url resolves against
|
||||
* whichever of the two the browser used — silently 404ing on deep routes.
|
||||
*/
|
||||
test("no url is relative", () => {
|
||||
const css = buildFontCss("/budget2.0/");
|
||||
expect(css).not.toContain('url("./');
|
||||
expect(css).not.toContain('url("fonts/');
|
||||
});
|
||||
|
||||
test("each face declares a weight range, not a single weight", () => {
|
||||
// Both faces are variable; one file must cover the whole scale.
|
||||
const css = buildFontCss("/");
|
||||
expect(css).toContain("font-weight: 100 900");
|
||||
expect(css).toContain("font-weight: 400 700");
|
||||
expect(css.match(/@font-face/g)).toHaveLength(FONT_FILES.length);
|
||||
});
|
||||
|
||||
test("every declared file is actually committed", async () => {
|
||||
for (const file of FONT_FILES) {
|
||||
expect(await Bun.file(`src/fonts/${file}`).exists()).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("the licences travel with the fonts, as the OFL requires", async () => {
|
||||
for (const licence of ["OFL-Inter.txt", "OFL-JetBrainsMono.txt"]) {
|
||||
const text = await Bun.file(`src/fonts/${licence}`).text();
|
||||
expect(text).toContain("SIL Open Font License");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* The one definition of the app's @font-face rules, shared by `build.ts`
|
||||
* (which writes it to dist/fonts.css) and the dev server (which generates it
|
||||
* per request) — the same shape as src/manifest.ts, and for the same reason:
|
||||
* two copies is how the manifest came to hardcode `scope: "/"`.
|
||||
*
|
||||
* Why this is NOT in src/index.css, where it belongs on the face of it: Bun's
|
||||
* CSS bundler inlines every woff2 it finds behind a `url()` as base64,
|
||||
* unconditionally — `loader: { ".woff2": "file" }` does not change it. That
|
||||
* turned the stylesheet into 303 KB of render-blocking bytes AND defeated the
|
||||
* `unicode-range` subsetting, since a subset embedded in the stylesheet is
|
||||
* downloaded whether or not a single character needs it. Served as files, the
|
||||
* browser fetches only the subsets a glyph actually requires.
|
||||
*/
|
||||
|
||||
/** Ordered, and the order is asserted by src/fontCss.test.ts. */
|
||||
export const FONT_FILES = [
|
||||
"inter-latin.woff2",
|
||||
"inter-latin-ext.woff2",
|
||||
"jetbrains-mono-latin.woff2",
|
||||
"jetbrains-mono-latin-ext.woff2",
|
||||
] as const;
|
||||
|
||||
/** Google's subset ranges, kept verbatim so the browser can skip a whole file. */
|
||||
const LATIN =
|
||||
"U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, " +
|
||||
"U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, " +
|
||||
"U+2212, U+2215, U+FEFF, U+FFFD";
|
||||
const LATIN_EXT =
|
||||
"U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, " +
|
||||
"U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, " +
|
||||
"U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF";
|
||||
|
||||
interface Face {
|
||||
family: string;
|
||||
weight: string;
|
||||
file: (typeof FONT_FILES)[number];
|
||||
range: string;
|
||||
}
|
||||
|
||||
const FACES: Face[] = [
|
||||
{ family: "Inter", weight: "100 900", file: "inter-latin.woff2", range: LATIN },
|
||||
{ family: "Inter", weight: "100 900", file: "inter-latin-ext.woff2", range: LATIN_EXT },
|
||||
{ family: "JetBrains Mono", weight: "400 700", file: "jetbrains-mono-latin.woff2", range: LATIN },
|
||||
{ family: "JetBrains Mono", weight: "400 700", file: "jetbrains-mono-latin-ext.woff2", range: LATIN_EXT },
|
||||
];
|
||||
|
||||
/**
|
||||
* `base` always ends in "/" (see normalizeBase). Urls must be absolute from
|
||||
* it: this stylesheet lives at the deploy root but the document can be several
|
||||
* segments deep, and a relative url would resolve against the document and
|
||||
* 404 on every route deeper than the base.
|
||||
*
|
||||
* `font-display: swap` because the first paint is a table of figures the owner
|
||||
* wants to read now; blocking that on a font download is the wrong trade.
|
||||
*/
|
||||
export function buildFontCss(base: string): string {
|
||||
const header =
|
||||
"/* Generated by src/fontCss.ts. Do not edit: build.ts writes this file\n" +
|
||||
" and the dev server serves the same output. SIL OFL 1.1 — see\n" +
|
||||
" src/fonts/OFL-Inter.txt and src/fonts/OFL-JetBrainsMono.txt. */\n";
|
||||
|
||||
return (
|
||||
header +
|
||||
FACES.map(
|
||||
(face) => `
|
||||
@font-face {
|
||||
font-family: "${face.family}";
|
||||
font-style: normal;
|
||||
font-weight: ${face.weight};
|
||||
font-display: swap;
|
||||
src: url("${base}fonts/${face.file}") format("woff2");
|
||||
unicode-range: ${face.range};
|
||||
}`,
|
||||
).join("\n") +
|
||||
"\n"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION AND CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -13,6 +13,14 @@ import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import { App } from "./ui/App.tsx";
|
||||
import { store } from "./store/index.ts";
|
||||
import { buildFontCss } from "./fontCss.ts";
|
||||
import { BASE_PATH } from "./ui/basePath.ts";
|
||||
|
||||
// Injected rather than imported: see the note in src/index.css. Done before
|
||||
// the store loads so the faces are already in flight while IndexedDB is read.
|
||||
const fontStyle = document.createElement("style");
|
||||
fontStyle.textContent = buildFontCss(BASE_PATH);
|
||||
document.head.appendChild(fontStyle);
|
||||
|
||||
const container = document.getElementById("root") ?? document.body;
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.9 KiB |
@@ -0,0 +1,73 @@
|
||||
import { test, expect, describe } from "bun:test";
|
||||
import { inflateSync } from "node:zlib";
|
||||
import { buildManifest } from "./manifest.ts";
|
||||
|
||||
/**
|
||||
* Chrome's install prompt wants raster icons at 192 and 512; an SVG alone
|
||||
* leaves it incomplete. These are generated from src/icon.svg by
|
||||
* `bun scripts/make-icons.ts` — see that file for why they are committed
|
||||
* rather than built.
|
||||
*/
|
||||
const SIZES = [192, 512] as const;
|
||||
|
||||
/** Width and height straight out of the IHDR chunk, which is always first. */
|
||||
function pngHeader(bytes: Uint8Array) {
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
const signature = [...bytes.slice(0, 8)];
|
||||
return {
|
||||
isPng: String.fromCharCode(...signature.slice(1, 4)) === "PNG",
|
||||
width: view.getUint32(16),
|
||||
height: view.getUint32(20),
|
||||
bitDepth: bytes[24],
|
||||
colorType: bytes[25],
|
||||
};
|
||||
}
|
||||
|
||||
/** Concatenated IDAT payloads, inflated. */
|
||||
function pixelBytes(bytes: Uint8Array): Buffer {
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
const chunks: Uint8Array[] = [];
|
||||
let i = 8;
|
||||
while (i < bytes.length) {
|
||||
const length = view.getUint32(i);
|
||||
const type = String.fromCharCode(...bytes.slice(i + 4, i + 8));
|
||||
if (type === "IDAT") chunks.push(bytes.slice(i + 8, i + 8 + length));
|
||||
i += 12 + length;
|
||||
}
|
||||
return inflateSync(Buffer.concat(chunks));
|
||||
}
|
||||
|
||||
describe("the app icons", () => {
|
||||
for (const size of SIZES) {
|
||||
test(`icon-${size}.png is a real ${size}x${size} image, not a placeholder`, async () => {
|
||||
const bytes = new Uint8Array(await Bun.file(`src/icon-${size}.png`).arrayBuffer());
|
||||
const header = pngHeader(bytes);
|
||||
|
||||
expect(header.isPng).toBe(true);
|
||||
expect(header.width).toBe(size);
|
||||
expect(header.height).toBe(size);
|
||||
expect(header.bitDepth).toBe(8);
|
||||
expect(header.colorType).toBe(6); // RGBA
|
||||
|
||||
// One filter byte plus 4 channels per pixel, per row. Proves the pixel
|
||||
// data is really present and really that size, so a truncated or
|
||||
// stub file cannot pass.
|
||||
expect(pixelBytes(bytes).length).toBe(size * (1 + size * 4));
|
||||
});
|
||||
}
|
||||
|
||||
test("the manifest offers both raster sizes as well as the SVG", () => {
|
||||
const icons = JSON.parse(buildManifest("/budget/")).icons as Array<{
|
||||
src: string;
|
||||
sizes: string;
|
||||
type: string;
|
||||
}>;
|
||||
expect(icons.map((i) => i.sizes)).toEqual(["any", "192x192", "512x512"]);
|
||||
expect(icons.map((i) => i.src)).toEqual([
|
||||
"/budget/icon.svg",
|
||||
"/budget/icon-192.png",
|
||||
"/budget/icon-512.png",
|
||||
]);
|
||||
expect(icons.every((i) => i.src.startsWith("/budget/"))).toBe(true);
|
||||
});
|
||||
});
|
||||
+173
-30
@@ -1,41 +1,163 @@
|
||||
@import "../styles/globals.css";
|
||||
|
||||
/*
|
||||
* Design contract tokens (Tasks 18-23).
|
||||
* See .superpowers/sdd/2026-09-01-budget-app/design-contract.md
|
||||
*
|
||||
* Namespaced as --budget-accent (not --accent): shadcn already defines a
|
||||
* bare --accent/--accent-foreground pair in styles/globals.css for hover
|
||||
* backgrounds on outline/ghost buttons, select items, badges, and the
|
||||
* dialog close button. A same-named override here would repaint that
|
||||
* background without repainting its paired foreground, breaking contrast
|
||||
* on every one of those controls. None of the other five tokens below
|
||||
* collide with a shadcn token name (checked against styles/globals.css).
|
||||
*
|
||||
* Unlayered (not inside @layer base) so these predictably win the cascade
|
||||
* over anything layered added later, without needing !important.
|
||||
*/
|
||||
:root {
|
||||
--paper: #f2f4f6; /* cool statement-paper ground, NOT cream */
|
||||
--ink: #16212b; /* ink navy, primary text */
|
||||
--rule: #d8dee4; /* hairlines, table borders */
|
||||
--budget-accent: #0f6e63; /* muted teal: links, primary actions, spend fill */
|
||||
--surplus: #2f6f4e; /* carried-forward surplus */
|
||||
--overspend: #a6402b; /* ochre-red: negative balances only */
|
||||
/* The @font-face rules are deliberately NOT here. Bun's CSS bundler resolves
|
||||
every url() from disk and inlines woff2 as base64 — relative OR absolute,
|
||||
and `loader: { ".woff2": "file" }` does not change it. That made this file
|
||||
303 KB of render-blocking bytes and defeated the unicode-range subsetting,
|
||||
since a subset embedded in the stylesheet downloads whether or not one
|
||||
character needs it. They live in src/fontCss.ts and are injected at boot by
|
||||
src/frontend.tsx, which is the only arrangement that is identical in dev and
|
||||
in a subpath production build without a placeholder file. The cost is that
|
||||
the faces are requested after the JS parses rather than from the HTML;
|
||||
`font-display: swap` keeps text readable throughout. */
|
||||
|
||||
--font-display: ui-sans-serif, "Segoe UI Variable Display", system-ui, sans-serif;
|
||||
--font-body: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
--font-money: ui-monospace, "SF Mono", "Cascadia Mono", "Roboto Mono", monospace;
|
||||
/*
|
||||
* ============================================================================
|
||||
* Budget 2.0 design foundation
|
||||
* ============================================================================
|
||||
*
|
||||
* Two layers, deliberately separate:
|
||||
*
|
||||
* 1. `--budget-*` semantic tokens, defined once per theme below. These are
|
||||
* the app's own vocabulary — paper, ink, rule, surplus, overspend — and
|
||||
* are the ONLY colours the app's own components should name.
|
||||
* 2. shadcn's variable set, pointed at layer 1. Re-theming shadcn rather
|
||||
* than fighting it means every primitive (dialog, select, badge, button)
|
||||
* inherits both themes for free.
|
||||
*
|
||||
* WHY THE PAIRS MATTER. CLAUDE.md's rule is "never define a bare `--accent`",
|
||||
* and it was paid for with an app-wide contrast bug: shadcn uses `--accent` as
|
||||
* a hover BACKGROUND and `--accent-foreground` as the text on it, so setting
|
||||
* one without the other repaints the background and leaves the text behind.
|
||||
* Every shadcn pair below is therefore redefined TOGETHER, in both themes.
|
||||
*
|
||||
* The brand teal is `--primary`, NOT `--accent`. That is the mapping the
|
||||
* earlier bug got wrong: shadcn's `--accent` is a subtle hover tint, and a
|
||||
* saturated teal there turns every hover into a coloured slab with dark text
|
||||
* on it. `--accent` stays a tint of the surface.
|
||||
*
|
||||
* Every text pair here was measured, not eyeballed: the weakest is 5.50:1
|
||||
* (muted on paper, light theme) against a 4.5:1 requirement, and field
|
||||
* borders clear the 3:1 non-text requirement on both of their backgrounds.
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* --- Layer 1: the app's own vocabulary (light) --- */
|
||||
--budget-paper: #f3f5f7; /* page ground: cool statement paper, NOT cream */
|
||||
--budget-raised: #ffffff; /* cards and rows sitting above the paper */
|
||||
--budget-ink: #16212b; /* primary text: ink navy, not black */
|
||||
--budget-ink-muted: #546575; /* labels, captions, secondary figures */
|
||||
--budget-rule: #dce3e9; /* hairlines between rows */
|
||||
--budget-rule-strong: #768899; /* field borders; needs 3:1, hairlines do not */
|
||||
--budget-accent: #0d6b60; /* muted teal: links, primary actions, spend fill */
|
||||
--budget-accent-ink: #ffffff; /* text ON the teal */
|
||||
--budget-tint: #e8edf1; /* hover wash, table header fill */
|
||||
--surplus: #1f6640; /* carried-forward surplus */
|
||||
--overspend: #a5382a; /* negative balances only */
|
||||
|
||||
/* Elevation is light and cool, never a grey drop shadow. */
|
||||
--budget-shadow-card: 0 1px 2px rgb(22 33 43 / 0.04), 0 8px 24px -12px rgb(22 33 43 / 0.12);
|
||||
--budget-shadow-raised: 0 1px 1px rgb(22 33 43 / 0.05), 0 2px 6px -2px rgb(22 33 43 / 0.08);
|
||||
|
||||
--font-display: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-body: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-money: "JetBrains Mono", ui-monospace, "SF Mono", monospace;
|
||||
|
||||
/* --- Layer 2: shadcn, pointed at layer 1. Pairs, always. --- */
|
||||
--radius: 0.5rem; /* crisper than shadcn's 0.625rem default */
|
||||
|
||||
--background: var(--budget-paper);
|
||||
--foreground: var(--budget-ink);
|
||||
--card: var(--budget-raised);
|
||||
--card-foreground: var(--budget-ink);
|
||||
--popover: var(--budget-raised);
|
||||
--popover-foreground: var(--budget-ink);
|
||||
--primary: var(--budget-accent);
|
||||
--primary-foreground: var(--budget-accent-ink);
|
||||
--secondary: var(--budget-tint);
|
||||
--secondary-foreground: var(--budget-ink);
|
||||
--muted: var(--budget-tint);
|
||||
--muted-foreground: var(--budget-ink-muted);
|
||||
--accent: var(--budget-tint); /* a TINT. see the note above. */
|
||||
--accent-foreground: var(--budget-ink); /* its pair, never omitted. */
|
||||
--destructive: var(--overspend);
|
||||
--border: var(--budget-rule);
|
||||
--input: var(--budget-rule-strong);
|
||||
--ring: var(--budget-accent);
|
||||
}
|
||||
|
||||
.dark {
|
||||
/* --- Layer 1 (dark). Deep slate, never pure black: a pure-black ground
|
||||
makes the hairlines and the carry meter float. --- */
|
||||
--budget-paper: #0c1319;
|
||||
--budget-raised: #141e28;
|
||||
--budget-ink: #e8eef4;
|
||||
--budget-ink-muted: #94a7b8;
|
||||
--budget-rule: #22303e;
|
||||
--budget-rule-strong: #647c92;
|
||||
--budget-accent: #3ab5a4; /* lifted: the light teal is unreadable on slate */
|
||||
--budget-accent-ink: #08110f; /* dark text on the lifted teal, not white */
|
||||
--budget-tint: #1c2836;
|
||||
--surplus: #57bb83;
|
||||
--overspend: #f0866a;
|
||||
|
||||
/* Dark elevation reads as a lighter surface plus a hairline, not a shadow:
|
||||
shadows are invisible on a dark ground. */
|
||||
--budget-shadow-card: 0 1px 0 rgb(255 255 255 / 0.04) inset, 0 12px 32px -16px rgb(0 0 0 / 0.7);
|
||||
--budget-shadow-raised: 0 1px 0 rgb(255 255 255 / 0.03) inset;
|
||||
|
||||
/* --- Layer 2 (dark). Same pairs, same rule. --- */
|
||||
--background: var(--budget-paper);
|
||||
--foreground: var(--budget-ink);
|
||||
--card: var(--budget-raised);
|
||||
--card-foreground: var(--budget-ink);
|
||||
--popover: var(--budget-raised);
|
||||
--popover-foreground: var(--budget-ink);
|
||||
--primary: var(--budget-accent);
|
||||
--primary-foreground: var(--budget-accent-ink);
|
||||
--secondary: var(--budget-tint);
|
||||
--secondary-foreground: var(--budget-ink);
|
||||
--muted: var(--budget-tint);
|
||||
--muted-foreground: var(--budget-ink-muted);
|
||||
--accent: var(--budget-tint);
|
||||
--accent-foreground: var(--budget-ink);
|
||||
--destructive: var(--overspend);
|
||||
--border: var(--budget-rule);
|
||||
--input: var(--budget-rule-strong);
|
||||
--ring: var(--budget-accent);
|
||||
}
|
||||
|
||||
/* Makes `font-sans` / `font-mono` utilities and shadcn primitives inherit the
|
||||
shipped faces instead of the OS stack. */
|
||||
@theme inline {
|
||||
--font-sans: var(--font-body);
|
||||
--font-mono: var(--font-money);
|
||||
--color-budget-accent: var(--budget-accent);
|
||||
--color-budget-paper: var(--budget-paper);
|
||||
--color-budget-raised: var(--budget-raised);
|
||||
--color-budget-rule: var(--budget-rule);
|
||||
--color-budget-ink: var(--budget-ink);
|
||||
--color-budget-ink-muted: var(--budget-ink-muted);
|
||||
--color-surplus: var(--surplus);
|
||||
--color-overspend: var(--overspend);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
@apply font-sans;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
.dark {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply grid place-items-center min-w-[320px] min-h-screen relative m-0 bg-background text-foreground;
|
||||
/* Was `grid place-items-center`, which centred short pages VERTICALLY —
|
||||
the month view floated in the middle of the viewport on an empty month.
|
||||
The app shell owns horizontal centring now, with a max width. */
|
||||
@apply m-0 min-w-[320px] min-h-screen bg-background text-foreground antialiased;
|
||||
font-family: var(--font-body);
|
||||
font-feature-settings: "cv11"; /* Inter's single-storey a-alternates off */
|
||||
}
|
||||
|
||||
h1,
|
||||
@@ -45,16 +167,28 @@
|
||||
h5,
|
||||
h6 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.02em;
|
||||
font-weight: 640;
|
||||
letter-spacing: -0.019em;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
/* One visible, consistent focus treatment. Keyboard users get a ring on the
|
||||
accent colour, which clears 3:1 on every surface in both themes. */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--budget-accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* Applied to every money figure: mono face, tabular digits. */
|
||||
/* Applied to every money figure: mono face, tabular digits so columns of
|
||||
numbers align on the decimal without a monospaced layout everywhere. */
|
||||
.font-money {
|
||||
font-family: var(--font-money);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-feature-settings: "zero"; /* slashed zero: 0 vs O at a glance */
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.text-overspend {
|
||||
@@ -64,6 +198,14 @@
|
||||
.text-surplus {
|
||||
color: var(--surplus);
|
||||
}
|
||||
|
||||
/* The one card treatment, so elevation cannot drift screen to screen. */
|
||||
.budget-card {
|
||||
background: var(--budget-raised);
|
||||
border: 1px solid var(--budget-rule);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--budget-shadow-card);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
@@ -80,5 +222,6 @@
|
||||
::before,
|
||||
::after {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,30 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Budget 2.0</title>
|
||||
<!-- Relative, NOT root-absolute: the dev server's HTML bundler resolves
|
||||
every <link href> from disk and has no `external` option, so "/x"
|
||||
fails to resolve and the whole page 500s. build.ts rewrites these to
|
||||
the deploy base for production. -->
|
||||
<link rel="manifest" href="manifest.webmanifest" />
|
||||
<link rel="icon" href="icon.svg" type="image/svg+xml" />
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
<!--
|
||||
Applies the saved theme BEFORE the stylesheet paints, so a dark-theme
|
||||
user never sees a flash of the light palette. It cannot import
|
||||
src/ui/theme.ts (it has to run before any module loads), so it repeats
|
||||
the key and the fallback; src/ui/theme.test.ts fails if the two drift.
|
||||
Wrapped because localStorage throws outright in some configurations.
|
||||
-->
|
||||
<script>
|
||||
try {
|
||||
var saved = localStorage.getItem("budget2:theme");
|
||||
var dark =
|
||||
saved === "dark" ||
|
||||
((saved === null || saved === "system") &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
if (dark) document.documentElement.classList.add("dark");
|
||||
} catch (e) {}
|
||||
</script>
|
||||
<script type="module" src="./frontend.tsx" async></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1,8 +1,71 @@
|
||||
import { serve } from "bun";
|
||||
import index from "./index.html";
|
||||
import { normalizeBase } from "./ui/basePath.ts";
|
||||
import { buildManifest } from "./manifest.ts";
|
||||
import { FONT_FILES } from "./fontCss.ts";
|
||||
|
||||
const basePath = normalizeBase(process.env.BUN_PUBLIC_BASE_PATH);
|
||||
|
||||
const server = serve({
|
||||
routes: {
|
||||
// index.html references both of these with a plain <link href>. Without
|
||||
// explicit routes the "/*" catch-all below answers them with the HTML
|
||||
// shell, so the manifest arrives as text/html (a parse error, and no
|
||||
// install prompt) and the icon never loads. Generated rather than served
|
||||
// from a committed file so the dev manifest cannot drift from the built
|
||||
// one — a stale copy is exactly how it came to hardcode scope "/".
|
||||
[`${basePath}manifest.webmanifest`]: () =>
|
||||
new Response(buildManifest(basePath), {
|
||||
headers: { "content-type": "application/manifest+json" },
|
||||
}),
|
||||
[`${basePath}icon.svg`]: () =>
|
||||
new Response(Bun.file(new URL("./icon.svg", import.meta.url)), {
|
||||
headers: { "content-type": "image/svg+xml" },
|
||||
}),
|
||||
[`${basePath}icon-192.png`]: () =>
|
||||
new Response(Bun.file(new URL("./icon-192.png", import.meta.url)), {
|
||||
headers: { "content-type": "image/png" },
|
||||
}),
|
||||
[`${basePath}icon-512.png`]: () =>
|
||||
new Response(Bun.file(new URL("./icon-512.png", import.meta.url)), {
|
||||
headers: { "content-type": "image/png" },
|
||||
}),
|
||||
// registerServiceWorker() asks for sw.js on every boot. Without this the
|
||||
// catch-all answers with the HTML shell and the browser logs
|
||||
// "The script has an unsupported MIME type ('text/html')" — caught and
|
||||
// harmless, but noise that hides real errors. A worker that claims no
|
||||
// clients and handles no fetches keeps registration honest in dev without
|
||||
// caching anything, which would fight HMR.
|
||||
[`${basePath}sw.js`]: () =>
|
||||
new Response(
|
||||
// Caches nothing — that would fight HMR — but it DOES honour the
|
||||
// lifecycle, so the update prompt's Reload button behaves in dev the
|
||||
// way it behaves in production instead of only ever hitting the
|
||||
// timeout backstop.
|
||||
[
|
||||
"/* dev worker: no caching, real lifecycle */",
|
||||
'self.addEventListener("install", () => self.skipWaiting());',
|
||||
'self.addEventListener("activate", (e) => e.waitUntil(self.clients.claim()));',
|
||||
'self.addEventListener("message", (e) => {',
|
||||
' if (e.data && e.data.type === "SKIP_WAITING") self.skipWaiting();',
|
||||
"});",
|
||||
"",
|
||||
].join("\n"),
|
||||
{
|
||||
headers: { "content-type": "text/javascript", "cache-control": "no-cache" },
|
||||
},
|
||||
),
|
||||
// The @font-face rules are injected by frontend.tsx; these serve the
|
||||
// files those rules point at.
|
||||
...Object.fromEntries(
|
||||
FONT_FILES.map((file) => [
|
||||
`${basePath}fonts/${file}`,
|
||||
() =>
|
||||
new Response(Bun.file(new URL(`./fonts/${file}`, import.meta.url)), {
|
||||
headers: { "content-type": "font/woff2", "cache-control": "no-cache" },
|
||||
}),
|
||||
]),
|
||||
),
|
||||
"/*": index,
|
||||
},
|
||||
development: process.env.NODE_ENV !== "production" && {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { buildManifest } from "./manifest.ts";
|
||||
|
||||
test("scope, start_url and the icon all follow the base path", () => {
|
||||
const manifest = JSON.parse(buildManifest("/budget2.0/"));
|
||||
expect(manifest.scope).toBe("/budget2.0/");
|
||||
expect(manifest.start_url).toBe("/budget2.0/");
|
||||
expect(manifest.icons[0].src).toBe("/budget2.0/icon.svg");
|
||||
});
|
||||
|
||||
test("a root deploy is the same shape, not a special case", () => {
|
||||
const manifest = JSON.parse(buildManifest("/"));
|
||||
expect(manifest.scope).toBe("/");
|
||||
expect(manifest.icons[0].src).toBe("/icon.svg");
|
||||
});
|
||||
|
||||
/**
|
||||
* `src/manifest.webmanifest` exists only so Bun's dev-server HTML bundler can
|
||||
* resolve the `<link rel="manifest">` href in src/index.html — Bun.serve has
|
||||
* no `external` option, unlike Bun.build. Nothing ever serves its contents:
|
||||
* the dev server answers that path from `buildManifest`, and build.ts writes
|
||||
* dist/manifest.webmanifest from it too.
|
||||
*
|
||||
* So the file must stay valueless. Real values here are unreachable, and a
|
||||
* hardcoded "scope": "/" in this file is exactly what made a subpath dev
|
||||
* deploy misleading.
|
||||
*/
|
||||
test("the dev resolution stub holds no manifest values that could go stale", async () => {
|
||||
const stub = await Bun.file("src/manifest.webmanifest").json();
|
||||
expect(Object.keys(stub)).toEqual(["$comment"]);
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* The one definition of the web app manifest, shared by `build.ts` (which
|
||||
* writes it into dist/) and the dev server (which generates it per request).
|
||||
* Two copies is how the dev placeholder came to hardcode `scope: "/"` while
|
||||
* production correctly used the base path.
|
||||
*
|
||||
* `base` always ends in "/" — see `normalizeBase` in src/ui/basePath.ts.
|
||||
*/
|
||||
export function buildManifest(base: string): string {
|
||||
return JSON.stringify(
|
||||
{
|
||||
name: "Budget 2.0",
|
||||
short_name: "Budget",
|
||||
start_url: base,
|
||||
scope: base,
|
||||
display: "standalone",
|
||||
background_color: "#0f172a",
|
||||
theme_color: "#0f172a",
|
||||
// The SVG first for anything that will scale it; the raster sizes
|
||||
// because Chrome's install prompt wants 192 and 512 specifically.
|
||||
icons: [
|
||||
{ src: `${base}icon.svg`, sizes: "any", type: "image/svg+xml" },
|
||||
{ src: `${base}icon-192.png`, sizes: "192x192", type: "image/png" },
|
||||
{ src: `${base}icon-512.png`, sizes: "512x512", type: "image/png" },
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,3 @@
|
||||
{
|
||||
"name": "Budget 2.0",
|
||||
"short_name": "Budget",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0f172a",
|
||||
"theme_color": "#0f172a",
|
||||
"icons": [{ "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml" }]
|
||||
"$comment": "Not served, and deliberately valueless. This file exists only so Bun's dev-server HTML bundler can resolve the <link rel=\"manifest\"> href in src/index.html; Bun.serve has no `external` option, unlike Bun.build. The manifest actually served comes from src/manifest.ts: the dev server generates it per request, and build.ts writes dist/manifest.webmanifest from it. Adding real values here does nothing except let them go stale - src/manifest.test.ts fails if you do."
|
||||
}
|
||||
@@ -3,8 +3,29 @@ import * as actions from "./actions.ts";
|
||||
import { createSeedDataset } from "../domain/seed.ts";
|
||||
import type { Dataset } from "../domain/types.ts";
|
||||
|
||||
/**
|
||||
* Digits now live in the dataset, so a currency with a different minor unit is
|
||||
* expressed by DEFINING one — no monkey-patching a module constant, which is
|
||||
* what this file used to need.
|
||||
*/
|
||||
function withDigits(currency: string, digits: number, body: () => void) {
|
||||
const original = zeroDigitDataset;
|
||||
zeroDigitDataset = { currency, digits };
|
||||
try {
|
||||
body();
|
||||
} finally {
|
||||
zeroDigitDataset = original;
|
||||
}
|
||||
}
|
||||
let zeroDigitDataset: { currency: string; digits: number } | null = null;
|
||||
|
||||
function draft(): Dataset {
|
||||
return createSeedDataset("2026-09");
|
||||
const data = createSeedDataset("2026-09");
|
||||
if (zeroDigitDataset) {
|
||||
const target = data.currencies.find((c) => c.code === zeroDigitDataset!.currency);
|
||||
if (target) target.digits = zeroDigitDataset.digits;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
describe("months", () => {
|
||||
@@ -378,3 +399,59 @@ describe("rule versions", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("split values are rounded by what they MEAN", () => {
|
||||
// In "fixed" mode a split value is money in the purchase's currency, so it
|
||||
// must round to that currency's minor unit. In "percent" mode it is a
|
||||
// percentage and has nothing to do with any currency.
|
||||
test("a fixed-mode split rounds to the purchase currency's minor unit", () => {
|
||||
withDigits("EUR", 0, () => {
|
||||
const data = draft();
|
||||
const purchase = actions.addPurchase(data, {
|
||||
date: "2026-09-14",
|
||||
description: "Sofa",
|
||||
total: { amount: 100, currency: "EUR" },
|
||||
splitMode: "fixed",
|
||||
splits: [
|
||||
{ postId: data.posts[0]!.id, value: 10.44, absorbsRemainder: false },
|
||||
{ postId: data.posts[1]!.id, value: 89.56, absorbsRemainder: true },
|
||||
],
|
||||
schedule: null,
|
||||
});
|
||||
expect(purchase.splits[0]!.value).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
test("a fixed-mode split still rounds by currency when only splits change", () => {
|
||||
withDigits("EUR", 0, () => {
|
||||
const data = draft();
|
||||
const purchase = actions.addPurchase(data, {
|
||||
date: "2026-09-14",
|
||||
description: "Sofa",
|
||||
total: { amount: 100, currency: "EUR" },
|
||||
splitMode: "fixed",
|
||||
splits: [{ postId: data.posts[0]!.id, value: 100, absorbsRemainder: true }],
|
||||
schedule: null,
|
||||
});
|
||||
actions.updatePurchase(data, purchase.id, {
|
||||
splits: [{ postId: data.posts[0]!.id, value: 42.61, absorbsRemainder: true }],
|
||||
});
|
||||
expect(data.purchases[0]!.splits[0]!.value).toBe(43);
|
||||
});
|
||||
});
|
||||
|
||||
test("a percent-mode split keeps 2 decimals whatever the currency does", () => {
|
||||
withDigits("EUR", 0, () => {
|
||||
const data = draft();
|
||||
const purchase = actions.addPurchase(data, {
|
||||
date: "2026-09-14",
|
||||
description: "Sofa",
|
||||
total: { amount: 100, currency: "EUR" },
|
||||
splitMode: "percent",
|
||||
splits: [{ postId: data.posts[0]!.id, value: 33.3333, absorbsRemainder: true }],
|
||||
schedule: null,
|
||||
});
|
||||
expect(purchase.splits[0]!.value).toBe(33.33);
|
||||
});
|
||||
});
|
||||
});
|
||||
+107
-17
@@ -1,8 +1,10 @@
|
||||
import { newId } from "../domain/seed.ts";
|
||||
import { compareMonths, monthOf } from "../domain/months.ts";
|
||||
import { roundMoney } from "../domain/money.ts";
|
||||
import { currencyUsage, digitsFor, normalizeCurrencyCode } from "../domain/currencies.ts";
|
||||
import type {
|
||||
Currency,
|
||||
CurrencyDef,
|
||||
Dataset,
|
||||
FxRate,
|
||||
Money,
|
||||
@@ -46,28 +48,47 @@ function round2(value: number): number {
|
||||
return Number(value.toFixed(2));
|
||||
}
|
||||
|
||||
function roundMoneyValue(money: Money): Money {
|
||||
return { amount: roundMoney(money.amount, money.currency), currency: money.currency };
|
||||
function roundMoneyValue(draft: Dataset, money: Money): Money {
|
||||
return {
|
||||
amount: roundMoney(money.amount, digitsFor(draft.currencies, money.currency)),
|
||||
currency: money.currency,
|
||||
};
|
||||
}
|
||||
|
||||
function roundRule(rule: Rule): Rule {
|
||||
function roundRule(draft: Dataset, rule: Rule): Rule {
|
||||
if (rule.kind === "fixed") {
|
||||
return { kind: "fixed", amount: roundMoneyValue(rule.amount) };
|
||||
return { kind: "fixed", amount: roundMoneyValue(draft, rule.amount) };
|
||||
}
|
||||
return { kind: "percentOfIncome", percent: round2(rule.percent) };
|
||||
}
|
||||
|
||||
function roundSplits(splits: Split[]): Split[] {
|
||||
return splits.map((split) => ({ ...split, value: round2(split.value) }));
|
||||
/**
|
||||
* A split's `value` is a percentage in "percent" mode and MONEY in the
|
||||
* purchase's currency in "fixed" mode, so the two round differently: a
|
||||
* percentage to 2 decimals, money to its currency's minor unit. Rounding
|
||||
* both to a hardcoded 2 places is correct only while every supported
|
||||
* currency happens to use hundredths.
|
||||
*/
|
||||
function roundSplits(
|
||||
draft: Dataset,
|
||||
splits: Split[],
|
||||
splitMode: Purchase["splitMode"],
|
||||
currency: Currency,
|
||||
): Split[] {
|
||||
const digits = digitsFor(draft.currencies, currency);
|
||||
return splits.map((split) => ({
|
||||
...split,
|
||||
value: splitMode === "fixed" ? roundMoney(split.value, digits) : round2(split.value),
|
||||
}));
|
||||
}
|
||||
|
||||
function roundSchedule(schedule: Schedule | null): Schedule | null {
|
||||
function roundSchedule(draft: Dataset, schedule: Schedule | null): Schedule | null {
|
||||
if (!schedule) return null;
|
||||
return {
|
||||
...schedule,
|
||||
slices: schedule.slices.map((slice) => ({
|
||||
month: slice.month,
|
||||
amount: roundMoneyValue(slice.amount),
|
||||
amount: roundMoneyValue(draft, slice.amount),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -86,7 +107,7 @@ export function ensureMonth(draft: Dataset, monthId: MonthId): Month {
|
||||
}
|
||||
|
||||
export function setIncome(draft: Dataset, monthId: MonthId, income: Money): void {
|
||||
ensureMonth(draft, monthId).income = roundMoneyValue(income);
|
||||
ensureMonth(draft, monthId).income = roundMoneyValue(draft, income);
|
||||
}
|
||||
|
||||
export function setRuleOverride(
|
||||
@@ -100,7 +121,7 @@ export function setRuleOverride(
|
||||
delete month.ruleOverrides[postId];
|
||||
return;
|
||||
}
|
||||
month.ruleOverrides[postId] = roundRule(rule);
|
||||
month.ruleOverrides[postId] = roundRule(draft, rule);
|
||||
}
|
||||
|
||||
export function addPost(draft: Dataset, name: string, currency: Currency): Post {
|
||||
@@ -158,7 +179,7 @@ export function setRuleFrom(
|
||||
rule: Rule,
|
||||
): void {
|
||||
const post = requirePost(draft, postId);
|
||||
const version: RuleVersion = { from, rule: roundRule(rule) };
|
||||
const version: RuleVersion = { from, rule: roundRule(draft, rule) };
|
||||
const existing = post.rules.findIndex((v) => v.from === from);
|
||||
if (existing === -1) post.rules.push(version);
|
||||
else post.rules[existing] = version;
|
||||
@@ -181,9 +202,9 @@ export function addPurchase(
|
||||
const created: Purchase = {
|
||||
...purchase,
|
||||
id: newId(),
|
||||
total: roundMoneyValue(purchase.total),
|
||||
splits: roundSplits(purchase.splits),
|
||||
schedule: roundSchedule(purchase.schedule),
|
||||
total: roundMoneyValue(draft, purchase.total),
|
||||
splits: roundSplits(draft, purchase.splits, purchase.splitMode, purchase.total.currency),
|
||||
schedule: roundSchedule(draft, purchase.schedule),
|
||||
};
|
||||
draft.purchases.push(created);
|
||||
// Make sure every month the purchase touches exists, so income can be
|
||||
@@ -203,13 +224,21 @@ export function updatePurchase(
|
||||
const purchase = requirePurchase(draft, purchaseId);
|
||||
const resolved: Partial<Omit<Purchase, "id">> = { ...changes };
|
||||
if (changes.total) {
|
||||
resolved.total = roundMoneyValue(changes.total);
|
||||
resolved.total = roundMoneyValue(draft, changes.total);
|
||||
}
|
||||
if (changes.splits) {
|
||||
resolved.splits = roundSplits(changes.splits);
|
||||
// Either field may be absent from a partial update; the stored purchase
|
||||
// supplies whichever one is, so the mode and currency always agree with
|
||||
// the values being rounded.
|
||||
resolved.splits = roundSplits(
|
||||
draft,
|
||||
changes.splits,
|
||||
changes.splitMode ?? purchase.splitMode,
|
||||
(resolved.total ?? purchase.total).currency,
|
||||
);
|
||||
}
|
||||
if (changes.schedule !== undefined) {
|
||||
resolved.schedule = roundSchedule(changes.schedule);
|
||||
resolved.schedule = roundSchedule(draft, changes.schedule);
|
||||
}
|
||||
Object.assign(purchase, resolved);
|
||||
}
|
||||
@@ -235,6 +264,67 @@ export function cancelScheduleFrom(
|
||||
* kept at six decimal places (see fxApi.ts's toFixed(6)), and rounding to a
|
||||
* currency's minor unit would quantize a rate below 0.01 straight to zero.
|
||||
*/
|
||||
/** Codes are identity, so they are validated the same way on every path. */
|
||||
const CURRENCY_CODE = /^[A-Z]{2,8}$/;
|
||||
|
||||
function requireCurrencyDef(def: CurrencyDef): CurrencyDef {
|
||||
const code = normalizeCurrencyCode(def.code);
|
||||
if (!CURRENCY_CODE.test(code)) {
|
||||
throw new Error(`Currency code "${def.code}" must be 2-8 letters`);
|
||||
}
|
||||
if (!Number.isInteger(def.digits) || def.digits < 0 || def.digits > 4) {
|
||||
throw new Error(
|
||||
`Currency "${code}" needs a whole number of decimal places between 0 and 4`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
code,
|
||||
digits: def.digits,
|
||||
...(def.symbol?.trim() ? { symbol: def.symbol.trim() } : {}),
|
||||
...(def.name?.trim() ? { name: def.name.trim() } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function addCurrency(draft: Dataset, def: CurrencyDef): CurrencyDef {
|
||||
const currency = requireCurrencyDef(def);
|
||||
if (draft.currencies.some((existing) => existing.code === currency.code)) {
|
||||
throw new Error(`Currency ${currency.code} is already defined`);
|
||||
}
|
||||
draft.currencies.push(currency);
|
||||
return currency;
|
||||
}
|
||||
|
||||
export function updateCurrency(
|
||||
draft: Dataset,
|
||||
code: Currency,
|
||||
changes: Partial<Omit<CurrencyDef, "code">>,
|
||||
): void {
|
||||
const existing = draft.currencies.find((currency) => currency.code === code);
|
||||
if (!existing) throw new Error(`Unknown currency: ${code}`);
|
||||
// The code is identity — it keys the FX table and every stored Money — so it
|
||||
// is deliberately not editable. Renaming one means adding the new code,
|
||||
// moving what refers to it, and removing the old.
|
||||
Object.assign(existing, requireCurrencyDef({ ...existing, ...changes, code }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuses to remove a currency anything still refers to, naming what refers to
|
||||
* it. Its exchange rate goes with it, so no orphan row survives.
|
||||
*/
|
||||
export function removeCurrency(draft: Dataset, code: Currency): void {
|
||||
const used = currencyUsage(draft, code);
|
||||
if (used.length > 0) {
|
||||
const isBase = draft.settings.baseCurrency === code;
|
||||
throw new Error(
|
||||
isBase
|
||||
? `${code} is the base currency; choose another base before removing it`
|
||||
: `${code} is in use by ${used.slice(0, 3).join(", ")}${used.length > 3 ? ` and ${used.length - 3} more` : ""}`,
|
||||
);
|
||||
}
|
||||
draft.currencies = draft.currencies.filter((currency) => currency.code !== code);
|
||||
draft.fxRates = draft.fxRates.filter((rate) => rate.currency !== code);
|
||||
}
|
||||
|
||||
export function setFxRate(draft: Dataset, rate: FxRate): void {
|
||||
const index = draft.fxRates.findIndex((r) => r.currency === rate.currency);
|
||||
if (index === -1) draft.fxRates.push(rate);
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { test, expect, describe } from "bun:test";
|
||||
import { FALLBACK_FX_RATES, parseBakedRates } from "./bakedRates.ts";
|
||||
import { createSeedDataset } from "../domain/seed.ts";
|
||||
import type { FxRate } from "../domain/types.ts";
|
||||
import { MissingRateError, toBase } from "../domain/fx.ts";
|
||||
|
||||
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", "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);
|
||||
expect(FALLBACK_FX_RATES.every((r) => r.baseUnitsPerOne > 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reading what the build baked in", () => {
|
||||
const baked: FxRate[] = [
|
||||
{ currency: "USD", baseUnitsPerOne: 6.812345, updatedAt: "2026-09-02", source: "api" },
|
||||
{ currency: "EUR", baseUnitsPerOne: 7.456789, updatedAt: "2026-09-02", source: "api" },
|
||||
];
|
||||
|
||||
test("an unset variable falls back to the committed constants", () => {
|
||||
expect(parseBakedRates(undefined)).toEqual(FALLBACK_FX_RATES);
|
||||
});
|
||||
|
||||
test("a well-formed payload is used as-is", () => {
|
||||
expect(parseBakedRates(JSON.stringify(baked))).toEqual(baked);
|
||||
});
|
||||
|
||||
test("six decimal places survive — a rate is not money", () => {
|
||||
const [usd] = parseBakedRates(JSON.stringify(baked));
|
||||
expect(usd!.baseUnitsPerOne).toBe(6.812345);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["not JSON at all", "{oh dear"],
|
||||
["a non-array", JSON.stringify({ USD: 6.8 })],
|
||||
["an unsupported currency", JSON.stringify([{ currency: "GBP", baseUnitsPerOne: 8.6, updatedAt: "2026-09-02", source: "api" }])],
|
||||
["a non-positive rate", JSON.stringify([{ currency: "USD", baseUnitsPerOne: 0, updatedAt: "2026-09-02", source: "api" }])],
|
||||
["a missing rate value", JSON.stringify([{ currency: "USD", updatedAt: "2026-09-02", source: "api" }])],
|
||||
["an empty array", JSON.stringify([])],
|
||||
])("%s falls back rather than booting with junk", (_label, payload) => {
|
||||
expect(parseBakedRates(payload)).toEqual(FALLBACK_FX_RATES);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what baked rates are allowed to do", () => {
|
||||
test("they seed a new dataset", () => {
|
||||
const data = createSeedDataset("2026-09", FALLBACK_FX_RATES);
|
||||
expect(data.fxRates).toEqual([...FALLBACK_FX_RATES]);
|
||||
});
|
||||
|
||||
test("seeding without them still yields no rates at all", () => {
|
||||
expect(createSeedDataset("2026-09").fxRates).toEqual([]);
|
||||
});
|
||||
|
||||
test("the seeded rows are copies, so editing one cannot rewrite the constants", () => {
|
||||
const data = createSeedDataset("2026-09", FALLBACK_FX_RATES);
|
||||
data.fxRates[0]!.baseUnitsPerOne = 999;
|
||||
expect(FALLBACK_FX_RATES[0]!.baseUnitsPerOne).not.toBe(999);
|
||||
});
|
||||
|
||||
/**
|
||||
* The decision this guards: clearing a rate REMOVES the row so
|
||||
* MissingRateError surfaces, instead of silently converting money at a
|
||||
* number the user never chose. Baked rates seed; they never backstop.
|
||||
*/
|
||||
test("they do NOT backstop a rate the user has cleared", () => {
|
||||
const data = createSeedDataset("2026-09", FALLBACK_FX_RATES);
|
||||
data.fxRates = data.fxRates.filter((r) => r.currency !== "EUR");
|
||||
expect(() =>
|
||||
toBase(
|
||||
{ amount: 100, currency: "EUR" },
|
||||
data.settings.baseCurrency,
|
||||
data.fxRates,
|
||||
data.currencies,
|
||||
),
|
||||
).toThrow(MissingRateError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { SEED_CURRENCIES, type Currency, type FxRate } from "../domain/types.ts";
|
||||
|
||||
/**
|
||||
* Exchange rates baked in at build time so a fresh install can convert a EUR
|
||||
* purchase on day one instead of erroring.
|
||||
*
|
||||
* They seed a NEW dataset and nothing else. They deliberately do NOT act as a
|
||||
* fallback whenever a rate is missing: clearing a rate removes its row so
|
||||
* `MissingRateError` surfaces, rather than silently converting money at a
|
||||
* number the user never chose. `src/store/bakedRates.test.ts` pins that.
|
||||
*
|
||||
* This lives in the store layer, not the domain, because reading the ambient
|
||||
* environment is exactly what `src/domain/` may not do. `createSeedDataset`
|
||||
* takes the rates as an argument instead.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The last-resort rates, committed so a build with no network still produces a
|
||||
* working app. DKK per one unit, six decimals, never rounded to money.
|
||||
*
|
||||
* Labelled "manual" rather than "api" on purpose: Settings shows this field,
|
||||
* and a hardcoded constant must not claim it came from a rate service. The
|
||||
* values are real (frankfurter.app, DKK base, 2026-09-01); EUR barely moves
|
||||
* because DKK is pegged to it, so it ages well. USD does move — a stale USD
|
||||
* number here is the reason the user can always edit or re-fetch a rate.
|
||||
*/
|
||||
export const FALLBACK_FX_RATES: readonly FxRate[] = Object.freeze([
|
||||
Object.freeze({
|
||||
currency: "USD" as Currency,
|
||||
baseUnitsPerOne: 6.449532,
|
||||
updatedAt: "2026-09-01",
|
||||
source: "manual" as const,
|
||||
}),
|
||||
Object.freeze({
|
||||
currency: "EUR" as Currency,
|
||||
baseUnitsPerOne: 7.474959,
|
||||
updatedAt: "2026-09-01",
|
||||
source: "manual" as const,
|
||||
}),
|
||||
]);
|
||||
|
||||
/**
|
||||
* The currencies a baked payload must cover: the seed table minus the base.
|
||||
* Only the SEED currencies — a dataset that already exists is never seeded, so
|
||||
* currencies the owner added later are irrelevant here.
|
||||
*/
|
||||
const REQUIRED: Currency[] = SEED_CURRENCIES.map((c) => c.code).filter((c) => c !== "DKK");
|
||||
const SEED_CODES: Currency[] = SEED_CURRENCIES.map((c) => c.code);
|
||||
|
||||
function isUsableRate(value: unknown): value is FxRate {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const rate = value as Record<string, unknown>;
|
||||
return (
|
||||
SEED_CODES.includes(rate.currency as Currency) &&
|
||||
typeof rate.baseUnitsPerOne === "number" &&
|
||||
Number.isFinite(rate.baseUnitsPerOne) &&
|
||||
rate.baseUnitsPerOne > 0 &&
|
||||
typeof rate.updatedAt === "string" &&
|
||||
(rate.source === "api" || rate.source === "manual")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses what `build.ts` embedded, falling back to the committed constants on
|
||||
* anything unusable — an unset variable (a plain `bun --hot`), a build whose
|
||||
* fetch failed, or a malformed payload. Falling back beats throwing: this runs
|
||||
* during first-run seeding, and an app that will not boot is worse than an app
|
||||
* with a slightly stale USD rate the user can edit.
|
||||
*/
|
||||
export function parseBakedRates(raw: string | undefined): readonly FxRate[] {
|
||||
if (!raw) return FALLBACK_FX_RATES;
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return FALLBACK_FX_RATES;
|
||||
}
|
||||
|
||||
if (!Array.isArray(parsed) || !parsed.every(isUsableRate)) return FALLBACK_FX_RATES;
|
||||
|
||||
// A partial payload would leave one currency unconvertible on a fresh
|
||||
// install, which is the whole problem this feature exists to solve.
|
||||
const covered = new Set(parsed.map((rate) => rate.currency));
|
||||
if (!REQUIRED.every((currency) => covered.has(currency))) return FALLBACK_FX_RATES;
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* try/catch, not a `typeof process` guard. Bun inlines the literal only when
|
||||
* the variable is set; unset, the bare reference reaches the browser and
|
||||
* throws before the app boots. A `typeof` test survives inlining and discards
|
||||
* the inlined value. See `readBasePathEnv` in src/ui/basePath.ts.
|
||||
*/
|
||||
export function readBakedRatesEnv(): string | undefined {
|
||||
try {
|
||||
return process.env.BUN_PUBLIC_BAKED_FX_RATES;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export const BAKED_FX_RATES = parseBakedRates(readBakedRatesEnv());
|
||||
@@ -0,0 +1,128 @@
|
||||
import { test, expect, describe } from "bun:test";
|
||||
import * as actions from "./actions.ts";
|
||||
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");
|
||||
|
||||
describe("adding a currency", () => {
|
||||
test("appends it with the digits and symbol given", () => {
|
||||
const data = draft();
|
||||
actions.addCurrency(data, { code: "JPY", digits: 0, symbol: "¥", name: "Japanese yen" });
|
||||
expect(data.currencies.at(-1)).toEqual({
|
||||
code: "JPY",
|
||||
digits: 0,
|
||||
symbol: "¥",
|
||||
name: "Japanese yen",
|
||||
});
|
||||
});
|
||||
|
||||
test("the code is normalised, because a code is identity", () => {
|
||||
// "usd" and "USD" must never become two currencies: the code keys the FX
|
||||
// table and every stored Money.
|
||||
const data = draft();
|
||||
actions.addCurrency(data, { code: " jpy ", digits: 0 });
|
||||
expect(data.currencies.at(-1)!.code).toBe("JPY");
|
||||
});
|
||||
|
||||
test("a duplicate is refused rather than shadowing the original", () => {
|
||||
const data = draft();
|
||||
expect(() => actions.addCurrency(data, { code: "usd", digits: 2 })).toThrow(/USD/);
|
||||
expect(data.currencies.filter((c) => c.code === "USD")).toHaveLength(1);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["not letters", "US1"],
|
||||
["too short", "U"],
|
||||
["empty", " "],
|
||||
])("a code that is %s is refused", (_label, code) => {
|
||||
expect(() => actions.addCurrency(draft(), { code, digits: 2 })).toThrow();
|
||||
});
|
||||
|
||||
test.each([[-1], [5], [1.5]])("invalid decimal places (%p) are refused", (digits) => {
|
||||
expect(() => actions.addCurrency(draft(), { code: "JPY", digits })).toThrow(/decimal/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("editing a currency", () => {
|
||||
test("changes the digits, symbol and name but never the code", () => {
|
||||
const data = draft();
|
||||
actions.updateCurrency(data, "USD", { digits: 3, symbol: "US$" });
|
||||
const usd = data.currencies.find((c) => c.code === "USD")!;
|
||||
expect(usd).toEqual({ code: "USD", digits: 3, symbol: "US$", name: "US dollar" });
|
||||
});
|
||||
|
||||
test("an unknown code throws", () => {
|
||||
expect(() => actions.updateCurrency(draft(), "JPY", { digits: 0 })).toThrow(/JPY/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removing a currency", () => {
|
||||
test("removes one nothing references", () => {
|
||||
const data = draft();
|
||||
actions.addCurrency(data, { code: "JPY", digits: 0 });
|
||||
actions.removeCurrency(data, "JPY");
|
||||
expect(data.currencies.map((c) => c.code)).toEqual(["DKK", "USD", "EUR"]);
|
||||
});
|
||||
|
||||
test("its exchange rate goes with it, so no orphan row is left behind", () => {
|
||||
const data = draft();
|
||||
actions.addCurrency(data, { code: "JPY", digits: 0 });
|
||||
actions.setFxRate(data, {
|
||||
currency: "JPY",
|
||||
baseUnitsPerOne: 0.045,
|
||||
updatedAt: "2026-09-01",
|
||||
source: "manual",
|
||||
});
|
||||
actions.removeCurrency(data, "JPY");
|
||||
expect(data.fxRates.find((r) => r.currency === "JPY")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("the base currency cannot be removed", () => {
|
||||
expect(() => actions.removeCurrency(draft(), "DKK")).toThrow(/base/i);
|
||||
});
|
||||
|
||||
/**
|
||||
* The same reasoning as posts being archived rather than deleted: stored
|
||||
* Money references the code. Removing a currency in use would leave amounts
|
||||
* pointing at a definition that no longer exists — and the import validator
|
||||
* would then refuse the owner's own exported file.
|
||||
*/
|
||||
test("a currency still referenced by a purchase cannot be removed", () => {
|
||||
const data = draft();
|
||||
actions.addPurchase(data, {
|
||||
date: "2026-09-04",
|
||||
description: "Indie bundle",
|
||||
total: { amount: 27.5, currency: "EUR" },
|
||||
splitMode: "percent",
|
||||
splits: [{ postId: data.posts[0]!.id, value: 100, absorbsRemainder: true }],
|
||||
schedule: null,
|
||||
});
|
||||
expect(() => actions.removeCurrency(data, "EUR")).toThrow(/in use/i);
|
||||
});
|
||||
|
||||
test("a currency referenced only by a post's display setting cannot be removed", () => {
|
||||
const data = draft();
|
||||
actions.updatePost(data, data.posts[0]!.id, { currency: "USD" });
|
||||
expect(() => actions.removeCurrency(data, "USD")).toThrow(/in use/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("currencyUsage", () => {
|
||||
test("names where a currency is referenced, so the UI can say why", () => {
|
||||
const data = draft();
|
||||
actions.setIncome(data, "2026-10", { amount: 100, currency: "USD" });
|
||||
expect(currencyUsage(data, "USD")).toContain("month 2026-10");
|
||||
expect(currencyUsage(data, "JPY")).toEqual([]);
|
||||
});
|
||||
|
||||
test("a fixed allocation rule counts as a reference", () => {
|
||||
const data = draft();
|
||||
actions.setRuleFrom(data, data.posts[0]!.id, "2026-09", {
|
||||
kind: "fixed",
|
||||
amount: { amount: 50, currency: "EUR" },
|
||||
});
|
||||
expect(currencyUsage(data, "EUR").join(" ")).toMatch(/rule/i);
|
||||
});
|
||||
});
|
||||
+18
-7
@@ -1,6 +1,9 @@
|
||||
import { test, expect, describe } from "bun:test";
|
||||
import { buildFxUrl, parseFxResponse, DEFAULT_FX_API_URL } from "./fxApi.ts";
|
||||
|
||||
/** What the caller asked the service for, and so what it will accept back. */
|
||||
const ALLOWED = ["DKK", "USD", "EUR"];
|
||||
|
||||
describe("buildFxUrl", () => {
|
||||
test("substitutes the base and target placeholders", () => {
|
||||
expect(buildFxUrl(DEFAULT_FX_API_URL, "DKK", ["USD", "EUR"])).toBe(
|
||||
@@ -13,28 +16,36 @@ describe("parseFxResponse", () => {
|
||||
test("inverts the rates into base units per one unit", () => {
|
||||
// The API returns how many target units 1 base unit buys; we store the
|
||||
// inverse, so 1 EUR = 7.46 DKK becomes baseUnitsPerOne 7.46.
|
||||
const rates = parseFxResponse({ rates: { EUR: 0.134048, USD: 0.144928 } }, "DKK");
|
||||
const rates = parseFxResponse({ rates: { EUR: 0.134048, USD: 0.144928 } }, "DKK", ALLOWED);
|
||||
expect(rates.find((r) => r.currency === "EUR")!.baseUnitsPerOne).toBeCloseTo(7.46, 2);
|
||||
expect(rates.every((r) => r.source === "api")).toBe(true);
|
||||
expect(rates.every((r) => r.updatedAt !== "")).toBe(true);
|
||||
});
|
||||
|
||||
test("skips the base currency's own row if the API returns one", () => {
|
||||
const rates = parseFxResponse({ rates: { DKK: 1, EUR: 0.134 } }, "DKK");
|
||||
const rates = parseFxResponse({ rates: { DKK: 1, EUR: 0.134 } }, "DKK", ALLOWED);
|
||||
expect(rates.map((r) => r.currency)).toEqual(["EUR"]);
|
||||
});
|
||||
|
||||
test("ignores unsupported currencies", () => {
|
||||
const rates = parseFxResponse({ rates: { GBP: 0.11, EUR: 0.134 } }, "DKK");
|
||||
test("ignores currencies this dataset does not define", () => {
|
||||
const rates = parseFxResponse({ rates: { GBP: 0.11, EUR: 0.134 } }, "DKK", ALLOWED);
|
||||
expect(rates.map((r) => r.currency)).toEqual(["EUR"]);
|
||||
});
|
||||
|
||||
test("rejects a response with no rates object", () => {
|
||||
expect(() => parseFxResponse({}, "DKK")).toThrow(/rates/);
|
||||
expect(() => parseFxResponse("nope", "DKK")).toThrow(/rates/);
|
||||
expect(() => parseFxResponse({}, "DKK", ALLOWED)).toThrow(/rates/);
|
||||
expect(() => parseFxResponse("nope", "DKK", ALLOWED)).toThrow(/rates/);
|
||||
});
|
||||
|
||||
test("rejects a zero or negative rate rather than storing an infinity", () => {
|
||||
expect(() => parseFxResponse({ rates: { EUR: 0 } }, "DKK")).toThrow(/EUR/);
|
||||
expect(() => parseFxResponse({ rates: { EUR: 0 } }, "DKK", ALLOWED)).toThrow(/EUR/);
|
||||
});
|
||||
});
|
||||
|
||||
test("an explicit updatedAt is used instead of the clock", () => {
|
||||
// The build bakes rates in with the BUILD date, not the date the browser
|
||||
// happens to run parseFxResponse.
|
||||
const rates = parseFxResponse({ rates: { EUR: 0.13378, USD: 0.15505 } }, "DKK", ALLOWED, "2026-09-01");
|
||||
expect(rates.map((r) => r.updatedAt)).toEqual(["2026-09-01", "2026-09-01"]);
|
||||
expect(rates.every((r) => r.source === "api")).toBe(true);
|
||||
});
|
||||
+16
-5
@@ -1,4 +1,4 @@
|
||||
import { CURRENCIES, type Currency, type FxRate } from "../domain/types.ts";
|
||||
import type { Currency, FxRate } from "../domain/types.ts";
|
||||
|
||||
/** Frankfurter needs no API key. The URL is user-editable in settings. */
|
||||
export const DEFAULT_FX_API_URL = "https://api.frankfurter.app/latest?from={base}&to={targets}";
|
||||
@@ -13,7 +13,18 @@ export function buildFxUrl(
|
||||
.replace("{targets}", targets.join(","));
|
||||
}
|
||||
|
||||
export function parseFxResponse(body: unknown, base: Currency): FxRate[] {
|
||||
export function parseFxResponse(
|
||||
body: unknown,
|
||||
base: Currency,
|
||||
/**
|
||||
* The currencies this dataset knows. Anything else in the response is
|
||||
* skipped: a rate service returns dozens, and storing one for a currency the
|
||||
* owner has not defined would put an unreachable row in their table.
|
||||
*/
|
||||
allowed: readonly Currency[],
|
||||
/** Defaults to now. The build passes its own date when baking rates in. */
|
||||
updatedAt: string = new Date().toISOString(),
|
||||
): FxRate[] {
|
||||
const rates =
|
||||
typeof body === "object" && body !== null
|
||||
? (body as Record<string, unknown>).rates
|
||||
@@ -22,12 +33,11 @@ export function parseFxResponse(body: unknown, base: Currency): FxRate[] {
|
||||
throw new Error("Exchange rate response has no `rates` object");
|
||||
}
|
||||
|
||||
const updatedAt = new Date().toISOString();
|
||||
const out: FxRate[] = [];
|
||||
|
||||
for (const [currency, value] of Object.entries(rates as Record<string, unknown>)) {
|
||||
if (currency === base) continue;
|
||||
if (!CURRENCIES.includes(currency as Currency)) continue;
|
||||
if (!allowed.includes(currency)) continue;
|
||||
if (typeof value !== "number" || !(value > 0)) {
|
||||
throw new Error(`Exchange rate for ${currency} is not a positive number`);
|
||||
}
|
||||
@@ -52,5 +62,6 @@ export async function fetchFxRates(
|
||||
if (!response.ok) {
|
||||
throw new Error(`Rate service returned ${response.status}`);
|
||||
}
|
||||
return parseFxResponse(await response.json(), base);
|
||||
// The targets we asked for are exactly the ones we will accept back.
|
||||
return parseFxResponse(await response.json(), base, targets);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { monthOf } from "../domain/months.ts";
|
||||
import { readDataset, writeDataset } from "./db.ts";
|
||||
import { createSnapshotStore } from "./snapshot.ts";
|
||||
import { BAKED_FX_RATES } from "./bakedRates.ts";
|
||||
|
||||
/** The current month, read at module load. The UI owns the clock; the domain does not. */
|
||||
export const currentMonth = monthOf(new Date().toISOString().slice(0, 10));
|
||||
@@ -8,6 +9,9 @@ export const currentMonth = monthOf(new Date().toISOString().slice(0, 10));
|
||||
export const store = createSnapshotStore(
|
||||
{ read: readDataset, write: writeDataset },
|
||||
currentMonth,
|
||||
// First run only, so a fresh install can convert a EUR purchase on day one.
|
||||
// Never a backstop for a rate the user has cleared.
|
||||
BAKED_FX_RATES,
|
||||
);
|
||||
|
||||
export * from "./snapshot.ts";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test, expect, describe } from "bun:test";
|
||||
import { SCHEMA_VERSION, createSeedDataset } from "../domain/seed.ts";
|
||||
import { migrate, UnsupportedSchemaError } from "./migrations.ts";
|
||||
import { createSeedDataset, SCHEMA_VERSION } from "../domain/seed.ts";
|
||||
|
||||
describe("migrate", () => {
|
||||
test("passes a current-version dataset through unchanged", () => {
|
||||
@@ -63,7 +63,10 @@ function v1Dataset() {
|
||||
describe("v1 -> v2: rules become a dated series", () => {
|
||||
test("each standingRule becomes one version starting at the fold start", () => {
|
||||
const migrated = migrate(v1Dataset());
|
||||
expect(migrated.settings.schemaVersion).toBe(2);
|
||||
// migrate() runs every step to the current SCHEMA_VERSION, so this lands
|
||||
// at 3 now that currencies became data. The rule conversion below is what
|
||||
// this test is actually about.
|
||||
expect(migrated.settings.schemaVersion).toBe(SCHEMA_VERSION);
|
||||
expect(migrated.posts[0]!.rules).toEqual([
|
||||
{ from: "2026-01", rule: { kind: "percentOfIncome", percent: 20 } },
|
||||
]);
|
||||
@@ -135,3 +138,98 @@ describe("v1 -> v2: rules become a dated series", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("v2 -> v3: currencies become data", () => {
|
||||
/** A realistic v2 dataset: no `currencies` field existed. */
|
||||
function v2(): any {
|
||||
return {
|
||||
settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 2 },
|
||||
fxRates: [
|
||||
{ currency: "EUR", baseUnitsPerOne: 7.4, updatedAt: "2026-01-01", source: "manual" },
|
||||
],
|
||||
posts: [
|
||||
{
|
||||
id: "p1",
|
||||
name: "Food",
|
||||
order: 0,
|
||||
archived: false,
|
||||
currency: "DKK",
|
||||
rules: [{ from: "2026-01", rule: { kind: "percentOfIncome", percent: 12 } }],
|
||||
},
|
||||
],
|
||||
months: [
|
||||
{ id: "2026-01", income: { amount: 20000, currency: "DKK" }, ruleOverrides: {} },
|
||||
],
|
||||
purchases: [],
|
||||
};
|
||||
}
|
||||
|
||||
test("the three shipped currencies are added, with their decimals", () => {
|
||||
const migrated = migrate(v2());
|
||||
expect(migrated.settings.schemaVersion).toBe(3);
|
||||
expect(migrated.currencies).toEqual([
|
||||
{ code: "DKK", digits: 2, symbol: "kr", name: "Danish krone" },
|
||||
{ code: "USD", digits: 2, symbol: "$", name: "US dollar" },
|
||||
{ code: "EUR", digits: 2, symbol: "€", name: "Euro" },
|
||||
]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The whole point of the migration being safe: every currency that existed
|
||||
* had two decimal places, and the seeded table says two, so not one stored
|
||||
* amount can round differently than it did before.
|
||||
*/
|
||||
test("no amount can round differently than it did under v2", () => {
|
||||
const migrated = migrate(v2());
|
||||
for (const currency of migrated.currencies) {
|
||||
expect(currency.digits).toBe(2);
|
||||
}
|
||||
});
|
||||
|
||||
test("nothing else about the dataset is touched", () => {
|
||||
const before = v2();
|
||||
const migrated = migrate(structuredClone(before));
|
||||
expect(migrated.posts).toEqual(before.posts);
|
||||
expect(migrated.months).toEqual(before.months);
|
||||
expect(migrated.fxRates).toEqual(before.fxRates);
|
||||
expect(migrated.settings.baseCurrency).toBe("DKK");
|
||||
expect(migrated.settings.foldStartMonth).toBe("2026-01");
|
||||
});
|
||||
|
||||
test("a v1 dataset migrates all the way through to v3", () => {
|
||||
const v1 = {
|
||||
settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 1 },
|
||||
fxRates: [],
|
||||
posts: [
|
||||
{
|
||||
id: "p1",
|
||||
name: "Food",
|
||||
order: 0,
|
||||
archived: false,
|
||||
currency: "DKK",
|
||||
standingRule: { kind: "fixed", amount: { amount: 500, currency: "DKK" } },
|
||||
},
|
||||
],
|
||||
months: [],
|
||||
purchases: [],
|
||||
};
|
||||
const migrated = migrate(v1);
|
||||
expect(migrated.settings.schemaVersion).toBe(3);
|
||||
expect(migrated.posts[0]!.rules).toEqual([
|
||||
{ from: "2026-01", rule: { kind: "fixed", amount: { amount: 500, currency: "DKK" } } },
|
||||
]);
|
||||
expect(migrated.currencies.map((c) => c.code)).toEqual(["DKK", "USD", "EUR"]);
|
||||
});
|
||||
|
||||
/**
|
||||
* A dataset can only reach v3 through this step, but an owner could have a
|
||||
* hand-edited file that already has the field. Overwriting it would silently
|
||||
* discard currencies they added.
|
||||
*/
|
||||
test("an existing currencies field is kept, not overwritten", () => {
|
||||
const data = v2();
|
||||
data.currencies = [{ code: "DKK", digits: 2 }, { code: "JPY", digits: 0 }];
|
||||
const migrated = migrate(data);
|
||||
expect(migrated.currencies.map((c) => c.code)).toEqual(["DKK", "JPY"]);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { SCHEMA_VERSION } from "../domain/seed.ts";
|
||||
import { SEED_CURRENCIES } from "../domain/types.ts";
|
||||
import type { Dataset } from "../domain/types.ts";
|
||||
|
||||
export class UnsupportedSchemaError extends Error {
|
||||
@@ -37,6 +38,24 @@ const MIGRATIONS: Array<(data: any) => any> = [
|
||||
};
|
||||
}),
|
||||
}),
|
||||
|
||||
// 2 -> 3: currencies become data. Until now the three supported currencies
|
||||
// and their decimal places were compile-time constants, so adding one meant
|
||||
// changing code; they now live in the dataset, which is also what lets the
|
||||
// owner define their own.
|
||||
//
|
||||
// Behaviour-preserving by construction: every currency that existed had two
|
||||
// decimal places, and the seeded table records two, so not one stored amount
|
||||
// can round differently than it did before. An existing `currencies` field
|
||||
// is left alone rather than overwritten — a hand-edited file could already
|
||||
// carry currencies the owner added, and discarding them would be data loss.
|
||||
(data: any) => ({
|
||||
...data,
|
||||
settings: { ...data.settings, schemaVersion: 3 },
|
||||
currencies: Array.isArray(data.currencies)
|
||||
? data.currencies
|
||||
: SEED_CURRENCIES.map((currency) => ({ ...currency })),
|
||||
}),
|
||||
];
|
||||
|
||||
export function migrate(raw: unknown): Dataset {
|
||||
|
||||
@@ -180,3 +180,28 @@ test("get before load throws rather than returning empty data", () => {
|
||||
const store = createSnapshotStore(fake.persistence, "2026-09");
|
||||
expect(() => store.get()).toThrow(/not loaded/i);
|
||||
});
|
||||
|
||||
describe("seed rates", () => {
|
||||
test("a first run seeds the rates it was given", async () => {
|
||||
const fake = fakePersistence(null);
|
||||
const rates = [
|
||||
{ currency: "EUR" as const, baseUnitsPerOne: 7.474959, updatedAt: "2026-09-01", source: "manual" as const },
|
||||
];
|
||||
const store = createSnapshotStore(fake.persistence, "2026-09", rates);
|
||||
await store.load();
|
||||
expect(store.get().fxRates).toEqual(rates);
|
||||
});
|
||||
|
||||
test("an existing dataset is never given seed rates", async () => {
|
||||
// Loading stored data must not touch fxRates — the user may have
|
||||
// deliberately cleared one, and re-adding it would convert money at a
|
||||
// number they never chose.
|
||||
const stored = createSeedDataset("2026-01");
|
||||
const fake = fakePersistence(stored);
|
||||
const store = createSnapshotStore(fake.persistence, "2026-09", [
|
||||
{ currency: "EUR", baseUnitsPerOne: 7.474959, updatedAt: "2026-09-01", source: "manual" },
|
||||
]);
|
||||
await store.load();
|
||||
expect(store.get().fxRates).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createSeedDataset } from "../domain/seed.ts";
|
||||
import type { Dataset, MonthId } from "../domain/types.ts";
|
||||
import type { Dataset, FxRate, MonthId } from "../domain/types.ts";
|
||||
|
||||
export interface Persistence {
|
||||
read(): Promise<Dataset | null>;
|
||||
@@ -19,6 +19,13 @@ export interface SnapshotStore {
|
||||
export function createSnapshotStore(
|
||||
persistence: Persistence,
|
||||
currentMonth: MonthId,
|
||||
/**
|
||||
* Rates for a FIRST RUN only. Passed in rather than read here so this module
|
||||
* stays free of the ambient environment; src/store/index.ts supplies
|
||||
* BAKED_FX_RATES. Loading an existing dataset never touches its rates — the
|
||||
* user may have cleared one deliberately.
|
||||
*/
|
||||
seedRates: readonly FxRate[] = [],
|
||||
): SnapshotStore {
|
||||
let snapshot: Dataset | null = null;
|
||||
const listeners = new Set<Listener>();
|
||||
@@ -64,7 +71,7 @@ export function createSnapshotStore(
|
||||
notify();
|
||||
return;
|
||||
}
|
||||
await commit(createSeedDataset(currentMonth));
|
||||
await commit(createSeedDataset(currentMonth, seedRates));
|
||||
},
|
||||
|
||||
get,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/// <reference lib="webworker" />
|
||||
declare const self: ServiceWorkerGlobalScope;
|
||||
|
||||
import { precacheUrls } from "./swPrecache.ts";
|
||||
|
||||
// Both are replaced at build time by Bun.build's `define`.
|
||||
const PRECACHE: string[] = JSON.parse(process.env.PRECACHE_MANIFEST ?? "[]");
|
||||
const VERSION = process.env.SW_VERSION ?? "dev";
|
||||
@@ -10,8 +12,10 @@ const CACHE = `budget2-${VERSION}`;
|
||||
const SHELL = `${BASE}index.html`;
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
// Deduped: addAll() rejects the whole install on a repeated request, and the
|
||||
// build's own outputs already contain the shell. See src/swPrecache.ts.
|
||||
event.waitUntil(
|
||||
caches.open(CACHE).then((cache) => cache.addAll([...PRECACHE, SHELL])),
|
||||
caches.open(CACHE).then((cache) => cache.addAll(precacheUrls(PRECACHE, SHELL))),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { test, expect, describe } from "bun:test";
|
||||
import { precacheUrls } from "./swPrecache.ts";
|
||||
|
||||
describe("the service worker's precache list", () => {
|
||||
/**
|
||||
* The bug this exists for: `cache.addAll()` rejects the WHOLE install with
|
||||
* InvalidStateError if the list contains the same request twice. The build's
|
||||
* precache list is derived from its own outputs, which already include
|
||||
* index.html, and the worker then appended the shell — index.html again. So
|
||||
* install failed every time, the worker went redundant, and no service
|
||||
* worker ever activated. Nothing reported it: registration itself succeeds,
|
||||
* and the failure is inside the install event.
|
||||
*/
|
||||
test("the shell is not repeated when the build already emitted it", () => {
|
||||
expect(precacheUrls(["/index.html", "/index-abc.js"], "/index.html")).toEqual([
|
||||
"/index.html",
|
||||
"/index-abc.js",
|
||||
]);
|
||||
});
|
||||
|
||||
test("the shell is added when the build did not emit it", () => {
|
||||
expect(precacheUrls(["/index-abc.js"], "/index.html")).toEqual([
|
||||
"/index-abc.js",
|
||||
"/index.html",
|
||||
]);
|
||||
});
|
||||
|
||||
test("any duplicate is removed, not just the shell", () => {
|
||||
expect(precacheUrls(["/a.js", "/b.css", "/a.js"], "/index.html")).toEqual([
|
||||
"/a.js",
|
||||
"/b.css",
|
||||
"/index.html",
|
||||
]);
|
||||
});
|
||||
|
||||
test("order is preserved, so the shell is not fetched before the assets", () => {
|
||||
expect(precacheUrls(["/z.js", "/a.css"], "/index.html")).toEqual([
|
||||
"/z.js",
|
||||
"/a.css",
|
||||
"/index.html",
|
||||
]);
|
||||
});
|
||||
|
||||
test("a subpath deploy is not a special case", () => {
|
||||
expect(
|
||||
precacheUrls(["/budget2.0/index.html", "/budget2.0/x.js"], "/budget2.0/index.html"),
|
||||
).toEqual(["/budget2.0/index.html", "/budget2.0/x.js"]);
|
||||
});
|
||||
|
||||
test("an empty build still precaches the shell", () => {
|
||||
expect(precacheUrls([], "/index.html")).toEqual(["/index.html"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* The exact list the service worker precaches on install.
|
||||
*
|
||||
* Separate from src/sw.ts so it can be tested: importing sw.ts outside a
|
||||
* worker would run its `self.addEventListener` calls.
|
||||
*
|
||||
* Deduplication is the whole point. `cache.addAll()` rejects the ENTIRE
|
||||
* install with InvalidStateError if the same request appears twice, and the
|
||||
* build's precache list is derived from its own outputs — which already
|
||||
* include index.html — while the worker also needs the shell by name. Those
|
||||
* two facts collided silently: install failed on every load, the worker went
|
||||
* redundant, and no service worker ever activated. Registration itself
|
||||
* succeeds, so nothing surfaced it.
|
||||
*
|
||||
* Deduping here rather than in build.ts means no future addition to either
|
||||
* list can reintroduce the collision.
|
||||
*/
|
||||
export function precacheUrls(precache: readonly string[], shell: string): string[] {
|
||||
return [...new Set([...precache, shell])];
|
||||
}
|
||||
+46
-15
@@ -9,26 +9,57 @@ import { SummaryRoute } from "./routes/SummaryRoute.tsx";
|
||||
import { SettingsRoute } from "./routes/SettingsRoute.tsx";
|
||||
import { UpdatePrompt } from "./components/UpdatePrompt.tsx";
|
||||
import { RouteErrorBoundaryReset } from "./components/ErrorBoundary.tsx";
|
||||
import { ThemeToggle } from "./components/ThemeToggle.tsx";
|
||||
|
||||
/**
|
||||
* One nav item. The active route is marked with weight and an accent
|
||||
* underline rather than colour alone, so it does not depend on colour vision,
|
||||
* and the underline is drawn on a fixed-height pseudo-row so switching tabs
|
||||
* never shifts the header's layout.
|
||||
*/
|
||||
function NavItem({ to, children }: { to: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
"relative rounded-md px-2.5 py-1.5 transition-colors",
|
||||
"after:absolute after:inset-x-2.5 after:-bottom-px after:h-0.5 after:rounded-full",
|
||||
isActive
|
||||
? "font-semibold text-budget-ink after:bg-budget-accent"
|
||||
: "text-budget-ink-muted hover:text-budget-ink hover:bg-accent after:bg-transparent",
|
||||
].join(" ")
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppRoutes() {
|
||||
const year = currentMonth.slice(0, 4);
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<nav className="flex gap-4 border-b px-6 py-3 text-sm">
|
||||
<NavLink to={`/month/${currentMonth}`} className="hover:underline">
|
||||
Month
|
||||
</NavLink>
|
||||
<NavLink to={`/year/${year}`} className="hover:underline">
|
||||
Year
|
||||
</NavLink>
|
||||
<NavLink to="/summary" className="hover:underline">
|
||||
Summary
|
||||
</NavLink>
|
||||
<NavLink to="/settings" className="ml-auto hover:underline">
|
||||
Settings
|
||||
</NavLink>
|
||||
</nav>
|
||||
<main className="p-6">
|
||||
{/* Sticky because the month view is long and the nav is how you leave
|
||||
it. Translucent with a blur so the paper ground still reads as one
|
||||
continuous sheet scrolling underneath. */}
|
||||
<header className="sticky top-0 z-30 border-b border-budget-rule bg-budget-paper/85 backdrop-blur-md">
|
||||
<div className="mx-auto flex max-w-6xl items-center gap-1 px-4 py-2.5 sm:px-6">
|
||||
<span className="mr-3 hidden text-sm font-semibold tracking-tight text-budget-ink sm:block">
|
||||
Budget<span className="text-budget-accent"> 2.0</span>
|
||||
</span>
|
||||
<nav className="flex items-center gap-1 text-sm">
|
||||
<NavItem to={`/month/${currentMonth}`}>Month</NavItem>
|
||||
<NavItem to={`/year/${year}`}>Year</NavItem>
|
||||
<NavItem to="/summary">Summary</NavItem>
|
||||
</nav>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<ThemeToggle />
|
||||
<NavItem to="/settings">Settings</NavItem>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="mx-auto max-w-6xl px-4 py-8 sm:px-6">
|
||||
<RouteErrorBoundaryReset>
|
||||
<Routes>
|
||||
<Route path="/month/:monthId" element={<MonthRoute />} />
|
||||
|
||||
@@ -45,15 +45,15 @@ describe("blank lines", () => {
|
||||
|
||||
describe("bulkTotal", () => {
|
||||
test("sums the filled lines", () => {
|
||||
expect(bulkTotal(draft({ lines: [line("Milk", 25), line("Bread", 30.5), line("", 0)] }))).toBe(55.5);
|
||||
expect(bulkTotal(draft({ lines: [line("Milk", 25), line("Bread", 30.5), line("", 0)] }), 2)).toBe(55.5);
|
||||
});
|
||||
|
||||
test("is zero when there is nothing filled in", () => {
|
||||
expect(bulkTotal(draft())).toBe(0);
|
||||
expect(bulkTotal(draft(), 2)).toBe(0);
|
||||
});
|
||||
|
||||
test("rounds the sum, so float tails never surface", () => {
|
||||
expect(bulkTotal(draft({ lines: [line("a", 0.1), line("b", 0.2)] }))).toBe(0.3);
|
||||
expect(bulkTotal(draft({ lines: [line("a", 0.1), line("b", 0.2)] }), 2)).toBe(0.3);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+2
-2
@@ -69,10 +69,10 @@ export function filledLines(draft: BulkDraft): BulkLine[] {
|
||||
return draft.lines.filter((line) => !isBlankLine(line));
|
||||
}
|
||||
|
||||
export function bulkTotal(draft: BulkDraft): number {
|
||||
export function bulkTotal(draft: BulkDraft, digits: number): number {
|
||||
return roundMoney(
|
||||
filledLines(draft).reduce((sum, line) => sum + line.amount, 0),
|
||||
draft.currency,
|
||||
digits,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useRef } from "react";
|
||||
import { useDataset } from "../hooks/useDataset.ts";
|
||||
import { digitsFor } from "../../domain/currencies.ts";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { formatMoney } from "../format.ts";
|
||||
@@ -24,6 +26,7 @@ interface Props {
|
||||
* on touch devices too. That is why there is no "Add line" button.
|
||||
*/
|
||||
export function BulkLines({ draft, onChange }: Props) {
|
||||
const digits = digitsFor(useDataset().currencies, draft.currency);
|
||||
const descriptionRefs = useRef<Array<HTMLInputElement | null>>([]);
|
||||
const noteRefs = useRef<Array<HTMLInputElement | null>>([]);
|
||||
|
||||
@@ -125,7 +128,7 @@ export function BulkLines({ draft, onChange }: Props) {
|
||||
|
||||
<p className="text-right text-xs text-muted-foreground">
|
||||
{filledCount} line{filledCount === 1 ? "" : "s"} ·{" "}
|
||||
<span className="font-money">{formatMoney(bulkTotal(draft), draft.currency)}</span>
|
||||
<span className="font-money">{formatMoney(bulkTotal(draft, digits), draft.currency)}</span>
|
||||
</p>
|
||||
</fieldset>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useDataset } from "../hooks/useDataset.ts";
|
||||
import { digitsFor } from "../../domain/currencies.ts";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { formatAmount } from "../format.ts";
|
||||
@@ -28,7 +30,10 @@ function hasValidDate(draft: PurchaseDraft): boolean {
|
||||
}
|
||||
|
||||
export function PlanEditor({ draft, onChange }: Props) {
|
||||
const balance = planBalance(draft);
|
||||
// The purchase's own currency decides the rounding, not the base currency:
|
||||
// a plan for a JPY purchase must divide into whole yen.
|
||||
const digits = digitsFor(useDataset().currencies, draft.currency);
|
||||
const balance = planBalance(draft, digits);
|
||||
|
||||
if (!draft.plan) {
|
||||
const dateReady = hasValidDate(draft);
|
||||
@@ -41,7 +46,7 @@ export function PlanEditor({ draft, onChange }: Props) {
|
||||
disabled={!dateReady}
|
||||
onClick={() => {
|
||||
if (!dateReady) return;
|
||||
onChange(withPlan(draft, monthOf(draft.date), 3));
|
||||
onChange(withPlan(draft, monthOf(draft.date), 3, digits));
|
||||
}}
|
||||
>
|
||||
Spread this over several months
|
||||
@@ -70,7 +75,7 @@ export function PlanEditor({ draft, onChange }: Props) {
|
||||
onChange={(event) => {
|
||||
const months = Number(event.target.value);
|
||||
if (Number.isInteger(months) && months >= 1) {
|
||||
onChange(withPlan(draft, draft.plan!.startMonth, months));
|
||||
onChange(withPlan(draft, draft.plan!.startMonth, months, digits));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -26,7 +26,7 @@ interface Props {
|
||||
* - --surplus : carried-in surplus (omitted when carriedIn <= 0)
|
||||
* - --budget-accent : spend within this month's allocation, filled from
|
||||
* the track's left edge
|
||||
* - --rule : the unspent remainder of the allocation track
|
||||
* - --budget-rule : the unspent remainder of the allocation track
|
||||
* - --overspend : spend beyond the allocation, past the track's end
|
||||
*/
|
||||
function carryMeterBackground(figures: PostMonthFigures): string {
|
||||
@@ -34,7 +34,7 @@ function carryMeterBackground(figures: PostMonthFigures): string {
|
||||
const scale = surplus + Math.max(figures.allocation, figures.charges);
|
||||
|
||||
if (scale <= 0) {
|
||||
return "var(--rule)";
|
||||
return "var(--budget-rule)";
|
||||
}
|
||||
|
||||
const pSurplus = (surplus / scale) * 100;
|
||||
@@ -45,7 +45,7 @@ function carryMeterBackground(figures: PostMonthFigures): string {
|
||||
`linear-gradient(to right, ` +
|
||||
`var(--surplus) 0%, var(--surplus) ${pSurplus}%, ` +
|
||||
`var(--budget-accent) ${pSurplus}%, var(--budget-accent) ${pFillEnd}%, ` +
|
||||
`var(--rule) ${pFillEnd}%, var(--rule) ${pTrackEnd}%, ` +
|
||||
`var(--budget-rule) ${pFillEnd}%, var(--budget-rule) ${pTrackEnd}%, ` +
|
||||
`var(--overspend) ${pTrackEnd}%, var(--overspend) 100%)`
|
||||
);
|
||||
}
|
||||
@@ -64,20 +64,20 @@ export function PostTable({ monthId, baseCurrency, rows, onChangeRule }: Props)
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b text-left text-muted-foreground">
|
||||
<tr>
|
||||
<th className="py-2">Post</th>
|
||||
<th className="py-2 text-right">Carried in</th>
|
||||
<th className="py-2 text-right">Allocated</th>
|
||||
<th className="py-2 text-right">Spent</th>
|
||||
<th className="py-2 text-right">Remaining</th>
|
||||
<thead className="text-left">
|
||||
<tr className="border-b border-budget-rule text-[0.6875rem] uppercase tracking-wider text-budget-ink-muted">
|
||||
<th className="py-2 font-medium">Post</th>
|
||||
<th className="py-2 pl-6 text-right font-medium">Carried in</th>
|
||||
<th className="py-2 pl-6 text-right font-medium">Allocated</th>
|
||||
<th className="py-2 pl-6 text-right font-medium">Spent</th>
|
||||
<th className="py-2 pl-6 text-right font-medium">Remaining</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(({ post, figures, overridden }) => (
|
||||
<Fragment key={post.id}>
|
||||
<tr>
|
||||
<td className="py-2">
|
||||
<tr className="group transition-colors hover:bg-accent/60">
|
||||
<td className="py-2.5">
|
||||
<Link to={`/post/${post.id}/month/${monthId}`} className="hover:underline">
|
||||
{post.name}
|
||||
</Link>
|
||||
@@ -111,7 +111,7 @@ export function PostTable({ monthId, baseCurrency, rows, onChangeRule }: Props)
|
||||
{onChangeRule && (
|
||||
<button
|
||||
type="button"
|
||||
className="ml-2 text-xs text-muted-foreground underline decoration-dotted"
|
||||
className="ml-2 rounded text-xs text-budget-ink-muted underline decoration-dotted underline-offset-2 opacity-0 transition-opacity hover:text-budget-accent focus-visible:opacity-100 group-hover:opacity-100"
|
||||
onClick={() => onChangeRule(post.id)}
|
||||
>
|
||||
change from here
|
||||
@@ -119,7 +119,7 @@ export function PostTable({ monthId, baseCurrency, rows, onChangeRule }: Props)
|
||||
)}
|
||||
</td>
|
||||
<td
|
||||
className={`font-money py-2 text-right ${
|
||||
className={`font-money py-2.5 pl-6 text-right ${
|
||||
figures.carriedIn > 0
|
||||
? "text-surplus"
|
||||
: figures.carriedIn < 0
|
||||
@@ -129,14 +129,14 @@ export function PostTable({ monthId, baseCurrency, rows, onChangeRule }: Props)
|
||||
>
|
||||
{formatSignedMoney(figures.carriedIn, baseCurrency)}
|
||||
</td>
|
||||
<td className="font-money py-2 text-right">
|
||||
<td className="font-money py-2.5 pl-6 text-right">
|
||||
{formatMoney(figures.allocation, baseCurrency)}
|
||||
</td>
|
||||
<td className="font-money py-2 text-right">
|
||||
<td className="font-money py-2.5 pl-6 text-right">
|
||||
{formatMoney(figures.charges, baseCurrency)}
|
||||
</td>
|
||||
<td
|
||||
className={`font-money py-2 text-right font-medium ${
|
||||
className={`font-money py-2.5 pl-6 text-right font-medium ${
|
||||
figures.remaining < 0 ? "text-overspend" : ""
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -27,9 +27,10 @@ import {
|
||||
type BulkDraft,
|
||||
} from "../bulkEntry.ts";
|
||||
import { useDataset } from "../hooks/useDataset.ts";
|
||||
import { parseMoneyInput } from "../moneyInput.ts";
|
||||
import { useMutate } from "../hooks/useMutate.ts";
|
||||
import { addPurchase, cancelScheduleFrom, updatePurchase } from "../../store/actions.ts";
|
||||
import { CURRENCIES, type MonthId, type Purchase } from "../../domain/types.ts";
|
||||
import type { MonthId, Purchase } from "../../domain/types.ts";
|
||||
|
||||
interface Props {
|
||||
monthId: MonthId;
|
||||
@@ -49,6 +50,11 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
|
||||
const [draft, setDraft] = useState<PurchaseDraft>(() =>
|
||||
purchase ? fromPurchase(purchase) : emptyDraft(monthId, activePosts[0]?.id ?? ""),
|
||||
);
|
||||
// The amount field keeps its own text so a half-typed "30." or "30 " stays
|
||||
// exactly as typed; `draft.amount` only moves when the text parses.
|
||||
const [amountText, setAmountText] = useState(() =>
|
||||
purchase ? String(purchase.total.amount) : "",
|
||||
);
|
||||
// "many" is only meaningful when adding — editing an existing purchase edits
|
||||
// exactly one, so the toggle is hidden in that case.
|
||||
const [mode, setMode] = useState<"one" | "many">("one");
|
||||
@@ -179,7 +185,7 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
|
||||
setBulk((b) => ({ ...b, currency }));
|
||||
}}
|
||||
>
|
||||
{CURRENCIES.map((currency) => (
|
||||
{dataset.currencies.map(({ code: currency }) => (
|
||||
<option key={currency} value={currency}>
|
||||
{currency}
|
||||
</option>
|
||||
@@ -247,15 +253,24 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="purchase-amount">Total</Label>
|
||||
{/* type="text", not "number": a number input silently discards
|
||||
anything non-numeric, so "30$" would never reach the parser.
|
||||
Entry speed is the point — the currency is set from what was
|
||||
typed rather than from a second control. */}
|
||||
<Input
|
||||
id="purchase-amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
inputMode="decimal"
|
||||
className="font-money"
|
||||
value={draft.amount}
|
||||
onChange={(event) =>
|
||||
setDraft({ ...draft, amount: Number(event.target.value) || 0 })
|
||||
}
|
||||
placeholder="30$"
|
||||
value={amountText}
|
||||
onChange={(event) => {
|
||||
const typed = event.target.value;
|
||||
setAmountText(typed);
|
||||
const parsed = parseMoneyInput(typed, dataset.currencies, draft.currency);
|
||||
if (parsed) {
|
||||
setDraft({ ...draft, amount: parsed.amount, currency: parsed.currency });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
@@ -268,7 +283,7 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
|
||||
setDraft({ ...draft, currency: event.target.value as typeof draft.currency })
|
||||
}
|
||||
>
|
||||
{CURRENCIES.map((currency) => (
|
||||
{dataset.currencies.map(({ code: currency }) => (
|
||||
<option key={currency} value={currency}>
|
||||
{currency}
|
||||
</option>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface Props {
|
||||
/** Omitted when the card's contents are self-explanatory (a toolbar). */
|
||||
title?: string;
|
||||
/** Sits opposite the title: a primary action, a mode switch. */
|
||||
action?: ReactNode;
|
||||
/** One line under the title. Long explanations belong in the body. */
|
||||
hint?: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one card in the app. Every screen is a stack of these on the paper
|
||||
* ground, which is what stops elevation, radius and padding drifting apart
|
||||
* screen by screen — the previous design defined a card treatment in a
|
||||
* contract document and then never applied it anywhere.
|
||||
*
|
||||
* The `.budget-card` class carries the visual treatment (see src/index.css) so
|
||||
* light and dark elevation differ where they must: a drop shadow is invisible
|
||||
* on a dark ground, where a lighter surface plus a hairline does the work.
|
||||
*/
|
||||
export function Section({ title, action, hint, children, className = "" }: Props) {
|
||||
return (
|
||||
<section className={`budget-card p-4 sm:p-5 ${className}`}>
|
||||
{(title || action) && (
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
{title && <h2 className="text-base leading-tight">{title}</h2>}
|
||||
{hint && (
|
||||
<p className="mt-1 text-xs leading-relaxed text-budget-ink-muted">{hint}</p>
|
||||
)}
|
||||
</div>
|
||||
{action && <div className="flex shrink-0 items-center gap-2">{action}</div>}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A single figure with its label. Used for the month's totals, where the
|
||||
* numbers matter more than the words: the label is small and quiet, the figure
|
||||
* is large and mono so a column of them lines up.
|
||||
*/
|
||||
export function Stat({
|
||||
label,
|
||||
children,
|
||||
tone = "default",
|
||||
title,
|
||||
}: {
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
tone?: "default" | "surplus" | "overspend";
|
||||
title?: string;
|
||||
}) {
|
||||
const toneClass =
|
||||
tone === "overspend" ? "text-overspend" : tone === "surplus" ? "text-surplus" : "";
|
||||
return (
|
||||
<div className="min-w-0" title={title}>
|
||||
<dt className="text-[0.6875rem] font-medium uppercase tracking-wider text-budget-ink-muted">
|
||||
{label}
|
||||
</dt>
|
||||
<dd className={`font-money mt-0.5 text-lg leading-tight ${toneClass}`}>{children}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useDataset } from "../hooks/useDataset.ts";
|
||||
import { digitsFor } from "../../domain/currencies.ts";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { formatAmount } from "../format.ts";
|
||||
@@ -13,7 +15,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export function SplitEditor({ draft, posts, onChange }: Props) {
|
||||
const balance = splitBalance(draft);
|
||||
const balance = splitBalance(draft, digitsFor(useDataset().currencies, draft.currency));
|
||||
const unit = draft.splitMode === "percent" ? "%" : draft.currency;
|
||||
|
||||
function updateSplit(index: number, changes: Partial<SplitDraft>) {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
applyResolvedTheme,
|
||||
prefersDark,
|
||||
readThemePreference,
|
||||
resolveTheme,
|
||||
storeThemePreference,
|
||||
type ThemePreference,
|
||||
} from "../theme.ts";
|
||||
|
||||
const ORDER: ThemePreference[] = ["system", "light", "dark"];
|
||||
|
||||
const LABEL: Record<ThemePreference, string> = {
|
||||
system: "Theme: follows your system",
|
||||
light: "Theme: light",
|
||||
dark: "Theme: dark",
|
||||
};
|
||||
|
||||
/**
|
||||
* Cycles system -> light -> dark. Three states rather than a two-way switch
|
||||
* because "follow the system" is a real answer and the only way to get back to
|
||||
* it, once you have chosen, is to have somewhere to choose it.
|
||||
*
|
||||
* While the preference is "system" this keeps listening, so the app follows
|
||||
* the OS switching to dark at sunset without a reload.
|
||||
*/
|
||||
export function ThemeToggle() {
|
||||
const [preference, setPreference] = useState<ThemePreference>(readThemePreference);
|
||||
|
||||
useEffect(() => {
|
||||
applyResolvedTheme(resolveTheme(preference, prefersDark()));
|
||||
if (preference !== "system") return;
|
||||
|
||||
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const follow = () => applyResolvedTheme(resolveTheme("system", media.matches));
|
||||
media.addEventListener("change", follow);
|
||||
return () => media.removeEventListener("change", follow);
|
||||
}, [preference]);
|
||||
|
||||
function cycle() {
|
||||
const next = ORDER[(ORDER.indexOf(preference) + 1) % ORDER.length]!;
|
||||
setPreference(next);
|
||||
storeThemePreference(next);
|
||||
}
|
||||
|
||||
const resolved = resolveTheme(preference, prefersDark());
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={cycle}
|
||||
title={`${LABEL[preference]}. Click to change.`}
|
||||
aria-label={`${LABEL[preference]}. Click to change.`}
|
||||
className="grid size-8 place-items-center rounded-md text-budget-ink-muted transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
{preference === "system" ? (
|
||||
<MonitorGlyph />
|
||||
) : resolved === "dark" ? (
|
||||
<MoonGlyph />
|
||||
) : (
|
||||
<SunGlyph />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* Inline so the app ships no icon library for three glyphs. currentColor
|
||||
throughout, so they follow the theme without a second set of tokens. */
|
||||
|
||||
function SunGlyph() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true" className="size-4" fill="none"
|
||||
stroke="currentColor" strokeWidth="1.4" strokeLinecap="round">
|
||||
<circle cx="8" cy="8" r="3.1" />
|
||||
<path d="M8 1.4v1.5M8 13.1v1.5M1.4 8h1.5M13.1 8h1.5M3.3 3.3l1.1 1.1M11.6 11.6l1.1 1.1M12.7 3.3l-1.1 1.1M4.4 11.6l-1.1 1.1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function MoonGlyph() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true" className="size-4" fill="none"
|
||||
stroke="currentColor" strokeWidth="1.4" strokeLinecap="round">
|
||||
<path d="M13.2 10.1A5.6 5.6 0 0 1 5.9 2.8a5.6 5.6 0 1 0 7.3 7.3Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function MonitorGlyph() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true" className="size-4" fill="none"
|
||||
stroke="currentColor" strokeWidth="1.4" strokeLinecap="round">
|
||||
<rect x="1.6" y="2.6" width="12.8" height="8.4" rx="1.3" />
|
||||
<path d="M5.6 13.4h4.8" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -15,61 +15,72 @@ interface Props {
|
||||
*/
|
||||
export function YearMatrix({ view, mode }: Props) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[48rem] text-sm">
|
||||
<thead className="border-b text-left text-muted-foreground">
|
||||
<tr>
|
||||
<th className="py-2">Post</th>
|
||||
{view.months.map((month) => (
|
||||
<th key={month} className="py-2 text-right font-normal">
|
||||
{month.slice(5)}
|
||||
</th>
|
||||
))}
|
||||
<th className="py-2 text-right">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-b text-muted-foreground">
|
||||
<td className="py-2">Income</td>
|
||||
{view.incomeByMonth.map((income, i) => (
|
||||
<td key={i} className="font-money py-2 text-right">
|
||||
{formatAmount(income)}
|
||||
</td>
|
||||
))}
|
||||
<td className="font-money py-2 text-right">{formatAmount(view.totalIncome)}</td>
|
||||
</tr>
|
||||
|
||||
{view.rows.map((row) => (
|
||||
<tr key={row.post.id} className="border-b last:border-0">
|
||||
<td className="py-2">
|
||||
<Link to={`/post/${row.post.id}/year/${view.year}`} className="hover:underline">
|
||||
{row.post.name}
|
||||
</Link>
|
||||
</td>
|
||||
{row.byMonth.map((figures, i) => {
|
||||
const value = mode === "spend" ? figures.charges : figures.remaining;
|
||||
return (
|
||||
<td
|
||||
key={i}
|
||||
className={`font-money py-2 text-right ${
|
||||
mode === "balance" && value < 0 ? "text-overspend" : ""
|
||||
}`}
|
||||
>
|
||||
{formatAmount(value)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td
|
||||
className={`font-money py-2 text-right font-medium ${
|
||||
mode === "balance" && row.closingBalance < 0 ? "text-overspend" : ""
|
||||
}`}
|
||||
>
|
||||
{formatAmount(mode === "spend" ? row.totalCharges : row.closingBalance)}
|
||||
// Thirteen columns of figures cannot fit any realistic card, so the table
|
||||
// scrolls. The fade tells you that: without it the matrix just appears to
|
||||
// end mid-number, which reads as clipping rather than as more to come.
|
||||
<div className="relative">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[84rem] text-sm">
|
||||
<thead className="text-left">
|
||||
<tr className="border-b border-budget-rule text-[0.6875rem] uppercase tracking-wider text-budget-ink-muted">
|
||||
<th className="py-2 font-medium">Post</th>
|
||||
{view.months.map((month) => (
|
||||
<th key={month} className="py-2 pl-4 text-right font-medium whitespace-nowrap">
|
||||
{month.slice(5)}
|
||||
</th>
|
||||
))}
|
||||
<th className="py-2 pl-4 text-right font-medium">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-b border-budget-rule text-budget-ink-muted">
|
||||
<td className="py-2">Income</td>
|
||||
{view.incomeByMonth.map((income, i) => (
|
||||
<td key={i} className="font-money py-2 pl-4 text-right whitespace-nowrap">
|
||||
{formatAmount(income)}
|
||||
</td>
|
||||
))}
|
||||
<td className="font-money py-2 pl-4 text-right whitespace-nowrap">
|
||||
{formatAmount(view.totalIncome)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{view.rows.map((row) => (
|
||||
<tr key={row.post.id} className="border-b border-budget-rule transition-colors last:border-0 hover:bg-accent/60">
|
||||
<td className="py-2">
|
||||
<Link to={`/post/${row.post.id}/year/${view.year}`} className="hover:underline">
|
||||
{row.post.name}
|
||||
</Link>
|
||||
</td>
|
||||
{row.byMonth.map((figures, i) => {
|
||||
const value = mode === "spend" ? figures.charges : figures.remaining;
|
||||
return (
|
||||
<td
|
||||
key={i}
|
||||
className={`font-money py-2 pl-4 text-right whitespace-nowrap ${
|
||||
mode === "balance" && value < 0 ? "text-overspend" : ""
|
||||
}`}
|
||||
>
|
||||
{formatAmount(value)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td
|
||||
className={`font-money py-2 pl-4 text-right font-medium whitespace-nowrap ${
|
||||
mode === "balance" && row.closingBalance < 0 ? "text-overspend" : ""
|
||||
}`}
|
||||
>
|
||||
{formatAmount(mode === "spend" ? row.totalCharges : row.closingBalance)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-y-0 right-0 w-10 bg-gradient-to-l from-budget-raised to-transparent"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { Glob } from "bun";
|
||||
|
||||
/**
|
||||
* Every custom property referenced from TypeScript through a CSS var()
|
||||
* expression must name a token that actually exists in src/index.css.
|
||||
*
|
||||
* This exists because renaming `--rule` to `--budget-rule` silently broke the
|
||||
* carry meter — the signature element of the month view. An undefined custom
|
||||
* property inside a `linear-gradient()` does not warn, does not throw and does
|
||||
* not fall back: the whole declaration is invalid, so the element simply
|
||||
* paints nothing. It looked like a missing feature, not a bug, and only pixel
|
||||
* sampling a screenshot found it.
|
||||
*
|
||||
* Tokens set by a third party at runtime are exempt — Radix writes
|
||||
* `--radix-*` onto its own elements, and they are never in our stylesheet.
|
||||
*/
|
||||
const RUNTIME_PROVIDED = /^--radix-/;
|
||||
|
||||
test("no TypeScript file references a CSS token that does not exist", async () => {
|
||||
const css = await Bun.file("src/index.css").text();
|
||||
const defined = new Set(
|
||||
[...css.matchAll(/^\s*(--[a-z0-9-]+)\s*:/gim)].map((m) => m[1]!),
|
||||
);
|
||||
|
||||
const missing: string[] = [];
|
||||
for await (const file of new Glob("src/**/*.{ts,tsx}").scan(".")) {
|
||||
const source = await Bun.file(file).text();
|
||||
for (const match of source.matchAll(/var\((--[a-z0-9-]+)\)/g)) {
|
||||
const token = match[1]!;
|
||||
if (RUNTIME_PROVIDED.test(token) || defined.has(token)) continue;
|
||||
const line = source.slice(0, match.index).split("\n").length;
|
||||
missing.push(`${file}:${line} references ${token}`);
|
||||
}
|
||||
}
|
||||
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { test, expect, describe } from "bun:test";
|
||||
import { parseMoneyInput, type CurrencyOption } from "./moneyInput.ts";
|
||||
|
||||
const OPTIONS: CurrencyOption[] = [
|
||||
{ code: "DKK", symbol: "kr" },
|
||||
{ code: "USD", symbol: "$" },
|
||||
{ code: "EUR", symbol: "€" },
|
||||
];
|
||||
const parse = (text: string, fallback = "DKK") =>
|
||||
parseMoneyInput(text, OPTIONS, fallback);
|
||||
|
||||
describe("reading the currency out of what was typed", () => {
|
||||
test("a trailing symbol", () => {
|
||||
expect(parse("30$")).toEqual({ amount: 30, currency: "USD" });
|
||||
});
|
||||
|
||||
test("a leading symbol", () => {
|
||||
expect(parse("$30")).toEqual({ amount: 30, currency: "USD" });
|
||||
});
|
||||
|
||||
test("a symbol with a space either side", () => {
|
||||
expect(parse("30 $")).toEqual({ amount: 30, currency: "USD" });
|
||||
expect(parse("$ 30")).toEqual({ amount: 30, currency: "USD" });
|
||||
});
|
||||
|
||||
test("a three-letter code, in any case", () => {
|
||||
expect(parse("30 usd")).toEqual({ amount: 30, currency: "USD" });
|
||||
expect(parse("USD 30")).toEqual({ amount: 30, currency: "USD" });
|
||||
expect(parse("30EUR")).toEqual({ amount: 30, currency: "EUR" });
|
||||
});
|
||||
|
||||
test("a multi-letter symbol", () => {
|
||||
expect(parse("250 kr")).toEqual({ amount: 250, currency: "DKK" });
|
||||
expect(parse("250kr")).toEqual({ amount: 250, currency: "DKK" });
|
||||
});
|
||||
|
||||
test("no currency at all falls back to the one the field is in", () => {
|
||||
expect(parse("30")).toEqual({ amount: 30, currency: "DKK" });
|
||||
expect(parse("30", "EUR")).toEqual({ amount: 30, currency: "EUR" });
|
||||
});
|
||||
|
||||
test("a code wins over a symbol when both appear", () => {
|
||||
// "$30 USD" is contradictory; the explicit code is the stronger signal.
|
||||
expect(parse("$30 USD")).toEqual({ amount: 30, currency: "USD" });
|
||||
});
|
||||
|
||||
test("an unknown currency is rejected rather than silently ignored", () => {
|
||||
// Silently dropping "GBP" would book a GBP purchase as DKK.
|
||||
expect(parse("30 GBP")).toBeNull();
|
||||
expect(parse("30 £")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("reading the number out of what was typed", () => {
|
||||
test("plain decimals", () => {
|
||||
expect(parse("30.50")).toEqual({ amount: 30.5, currency: "DKK" });
|
||||
});
|
||||
|
||||
test("a comma decimal separator, as most of Europe writes it", () => {
|
||||
expect(parse("30,50")).toEqual({ amount: 30.5, currency: "DKK" });
|
||||
expect(parse("€25,50")).toEqual({ amount: 25.5, currency: "EUR" });
|
||||
});
|
||||
|
||||
/**
|
||||
* The genuinely ambiguous cases. The rule: when both separators appear, the
|
||||
* LAST one is the decimal point. When only one appears, it is a thousands
|
||||
* separator if exactly three digits follow it, and a decimal point
|
||||
* otherwise. "1,500" is therefore one thousand five hundred, which is what
|
||||
* someone typing a budget figure means.
|
||||
*/
|
||||
test("both separators: the last one is the decimal point", () => {
|
||||
expect(parse("1.234,56")).toEqual({ amount: 1234.56, currency: "DKK" });
|
||||
expect(parse("1,234.56")).toEqual({ amount: 1234.56, currency: "DKK" });
|
||||
});
|
||||
|
||||
test("one separator with three digits after it groups thousands", () => {
|
||||
expect(parse("1,500")).toEqual({ amount: 1500, currency: "DKK" });
|
||||
expect(parse("1.500")).toEqual({ amount: 1500, currency: "DKK" });
|
||||
});
|
||||
|
||||
test("one separator with one or two digits after it is a decimal point", () => {
|
||||
expect(parse("1,5")).toEqual({ amount: 1.5, currency: "DKK" });
|
||||
expect(parse("1.50")).toEqual({ amount: 1.5, currency: "DKK" });
|
||||
});
|
||||
|
||||
test("spaces and non-breaking spaces group thousands", () => {
|
||||
expect(parse("28 000")).toEqual({ amount: 28000, currency: "DKK" });
|
||||
expect(parse("28 000,50")).toEqual({ amount: 28000.5, currency: "DKK" });
|
||||
});
|
||||
|
||||
test("a negative amount is legal — a refund is a normal line", () => {
|
||||
expect(parse("-500")).toEqual({ amount: -500, currency: "DKK" });
|
||||
expect(parse("-30$")).toEqual({ amount: -30, currency: "USD" });
|
||||
expect(parse("$-30")).toEqual({ amount: -30, currency: "USD" });
|
||||
});
|
||||
|
||||
test("a leading plus is accepted and means nothing", () => {
|
||||
expect(parse("+30")).toEqual({ amount: 30, currency: "DKK" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("what is not a money value", () => {
|
||||
test.each([
|
||||
["empty", ""],
|
||||
["only spaces", " "],
|
||||
["only a currency", "$"],
|
||||
["only a code", "USD"],
|
||||
["letters", "abc"],
|
||||
["two numbers", "30 40"],
|
||||
["a bare separator", ","],
|
||||
["a lone minus", "-"],
|
||||
])("%s is not a money value", (_label, text) => {
|
||||
expect(parse(text)).toBeNull();
|
||||
});
|
||||
|
||||
test("a half-typed decimal is not yet a value, and must not become one", () => {
|
||||
// Committing "30." as 30 would fight the user mid-keystroke.
|
||||
expect(parse("30.")).toBeNull();
|
||||
expect(parse("30,")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("symbols that collide", () => {
|
||||
const withCollision: CurrencyOption[] = [
|
||||
{ code: "USD", symbol: "$" },
|
||||
{ code: "CAD", symbol: "$" },
|
||||
];
|
||||
|
||||
test("the first currency defined with that symbol wins, and the code disambiguates", () => {
|
||||
expect(parseMoneyInput("30$", withCollision, "USD")).toEqual({
|
||||
amount: 30,
|
||||
currency: "USD",
|
||||
});
|
||||
expect(parseMoneyInput("30 CAD", withCollision, "USD")).toEqual({
|
||||
amount: 30,
|
||||
currency: "CAD",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { Currency } from "../domain/types.ts";
|
||||
|
||||
export interface CurrencyOption {
|
||||
code: Currency;
|
||||
/** "$", "kr", "€". Optional: a currency can be entered by code alone. */
|
||||
symbol?: string;
|
||||
}
|
||||
|
||||
export interface ParsedMoney {
|
||||
amount: number;
|
||||
currency: Currency;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads an amount AND its currency out of one field, so "30$" means thirty
|
||||
* dollars without touching a separate dropdown. Entry speed is a stated
|
||||
* product feature; a second control per amount is the opposite of it.
|
||||
*
|
||||
* Returns null for anything that is not yet a money value, including a
|
||||
* half-typed "30." — committing that as 30 would fight the user mid-keystroke.
|
||||
* An unrecognised currency also returns null rather than falling back, because
|
||||
* silently reading "30 GBP" as 30 DKK books money at the wrong currency.
|
||||
*/
|
||||
export function parseMoneyInput(
|
||||
text: string,
|
||||
options: readonly CurrencyOption[],
|
||||
fallback: Currency,
|
||||
): ParsedMoney | null {
|
||||
const trimmed = text.trim();
|
||||
if (trimmed === "") return null;
|
||||
|
||||
const currency = detectCurrency(trimmed, options);
|
||||
if (currency === "unknown") return null;
|
||||
|
||||
const amount = parseAmount(stripCurrency(trimmed, options));
|
||||
if (amount === null) return null;
|
||||
|
||||
return { amount, currency: currency ?? fallback };
|
||||
}
|
||||
|
||||
/**
|
||||
* A three-letter code beats a symbol: "$30 USD" is contradictory, and the code
|
||||
* is the more deliberate signal. Returns `null` when no currency was written
|
||||
* at all, and "unknown" when something currency-shaped was written that this
|
||||
* dataset has no currency for.
|
||||
*/
|
||||
function detectCurrency(
|
||||
text: string,
|
||||
options: readonly CurrencyOption[],
|
||||
): Currency | null | "unknown" {
|
||||
const codes = new Map(options.map((o) => [o.code.toUpperCase(), o.code]));
|
||||
|
||||
// A bare three-letter word, not part of a longer word.
|
||||
const words = text.toUpperCase().match(/[A-Z]{2,}/g) ?? [];
|
||||
for (const word of words) {
|
||||
const matched = codes.get(word);
|
||||
if (matched) return matched;
|
||||
}
|
||||
|
||||
// Longest symbol first, so "kr." cannot be shadowed by "kr".
|
||||
const symbols = options
|
||||
.filter((o) => o.symbol)
|
||||
.sort((a, b) => b.symbol!.length - a.symbol!.length);
|
||||
for (const option of symbols) {
|
||||
if (text.includes(option.symbol!)) return option.code;
|
||||
}
|
||||
|
||||
// Anything left that is neither digits, separators nor a sign is a currency
|
||||
// this dataset does not define.
|
||||
if (/[^\d\s.,+-]/.test(text)) return "unknown";
|
||||
|
||||
// A word that is not a known code is unknown too ("30 GBP").
|
||||
if (words.length > 0) return "unknown";
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function stripCurrency(text: string, options: readonly CurrencyOption[]): string {
|
||||
let out = text;
|
||||
for (const option of options) {
|
||||
if (option.symbol) out = out.split(option.symbol).join(" ");
|
||||
out = out.replace(new RegExp(option.code, "gi"), " ");
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Separator handling, which is where this earns its tests. When BOTH "." and
|
||||
* "," appear, the last one is the decimal point — that covers "1.234,56" and
|
||||
* "1,234.56" without knowing the user's locale. When only one appears, it
|
||||
* groups thousands if exactly three digits follow and is a decimal point
|
||||
* otherwise, so "1,500" is fifteen hundred and "1,5" is one and a half.
|
||||
*/
|
||||
function parseAmount(text: string): number | null {
|
||||
// Normalise every kind of space to a plain one before splitting.
|
||||
const spaced = text.replace(/\s+/g, " ").trim();
|
||||
if (spaced === "") return null;
|
||||
|
||||
const sign = spaced.startsWith("-") ? -1 : 1;
|
||||
const unsigned = spaced.replace(/^[+-]\s*/, "");
|
||||
|
||||
// A space only ever GROUPS thousands, so every group after the first must be
|
||||
// exactly three digits. Without this, "30 40" silently becomes 3040 — two
|
||||
// amounts typed by mistake, committed as one much larger one.
|
||||
const groups = unsigned.split(" ").filter((group) => group !== "");
|
||||
if (groups.length === 0) return null;
|
||||
if (groups.length > 1) {
|
||||
const allGrouped = groups
|
||||
.slice(1)
|
||||
.every((group) => /^\d{3}([.,]\d+)?$/.test(group));
|
||||
if (!allGrouped) return null;
|
||||
}
|
||||
|
||||
const digits = groups.join("");
|
||||
if (!/^[\d.,]+$/.test(digits)) return null;
|
||||
if (/^[.,]+$/.test(digits)) return null;
|
||||
// A trailing separator means the number is still being typed.
|
||||
if (/[.,]$/.test(digits)) return null;
|
||||
|
||||
const lastDot = digits.lastIndexOf(".");
|
||||
const lastComma = digits.lastIndexOf(",");
|
||||
let decimalAt = -1;
|
||||
|
||||
if (lastDot >= 0 && lastComma >= 0) {
|
||||
decimalAt = Math.max(lastDot, lastComma);
|
||||
} else if (lastDot >= 0 || lastComma >= 0) {
|
||||
const only = Math.max(lastDot, lastComma);
|
||||
if (digits.length - only - 1 !== 3) decimalAt = only;
|
||||
}
|
||||
|
||||
const whole = (decimalAt === -1 ? digits : digits.slice(0, decimalAt)).replace(
|
||||
/[.,]/g,
|
||||
"",
|
||||
);
|
||||
const fraction = decimalAt === -1 ? "" : digits.slice(decimalAt + 1);
|
||||
if (/[.,]/.test(fraction)) return null;
|
||||
|
||||
const value = Number(
|
||||
`${whole === "" ? "0" : whole}.${fraction === "" ? "0" : fraction}`,
|
||||
);
|
||||
return Number.isFinite(value) ? sign * value : null;
|
||||
}
|
||||
|
||||
+14
-13
@@ -30,7 +30,7 @@ describe("emptyDraft", () => {
|
||||
|
||||
describe("splitBalance", () => {
|
||||
test("is zero when percentages total 100", () => {
|
||||
expect(splitBalance(draft())).toBe(0);
|
||||
expect(splitBalance(draft(), 2)).toBe(0);
|
||||
});
|
||||
|
||||
test("reports the missing percentage", () => {
|
||||
@@ -40,7 +40,7 @@ describe("splitBalance", () => {
|
||||
{ postId: "events", value: 30, absorbsRemainder: false },
|
||||
],
|
||||
});
|
||||
expect(splitBalance(d)).toBe(10);
|
||||
expect(splitBalance(d, 2)).toBe(10);
|
||||
});
|
||||
|
||||
test("reports the missing amount in fixed mode", () => {
|
||||
@@ -51,7 +51,7 @@ describe("splitBalance", () => {
|
||||
{ postId: "events", value: 50, absorbsRemainder: false },
|
||||
],
|
||||
});
|
||||
expect(splitBalance(d)).toBe(30);
|
||||
expect(splitBalance(d, 2)).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -123,7 +123,7 @@ describe("toPurchase and fromPurchase", () => {
|
||||
|
||||
describe("finance plans", () => {
|
||||
test("withPlan generates equal slices from the given month", () => {
|
||||
const d = withPlan(draft({ amount: 3000 }), "2026-10", 6);
|
||||
const d = withPlan(draft({ amount: 3000 }), "2026-10", 6, 2);
|
||||
expect(d.plan!.slices).toHaveLength(6);
|
||||
expect(d.plan!.slices[0]).toEqual({
|
||||
month: "2026-10",
|
||||
@@ -140,37 +140,37 @@ describe("finance plans", () => {
|
||||
{ postId: "events", value: 30, absorbsRemainder: false },
|
||||
],
|
||||
});
|
||||
expect(withPlan(split, "2026-10", 2).splits).toEqual(split.splits);
|
||||
expect(withPlan(split, "2026-10", 2, 2).splits).toEqual(split.splits);
|
||||
});
|
||||
|
||||
test("withoutPlan clears the schedule", () => {
|
||||
expect(withoutPlan(withPlan(draft(), "2026-10", 3)).plan).toBeNull();
|
||||
expect(withoutPlan(withPlan(draft(), "2026-10", 3, 2)).plan).toBeNull();
|
||||
});
|
||||
|
||||
test("setSliceAmount edits one slice and leaves the others alone", () => {
|
||||
const d = setSliceAmount(withPlan(draft({ amount: 3000 }), "2026-10", 3), 0, 1500);
|
||||
const d = setSliceAmount(withPlan(draft({ amount: 3000 }), "2026-10", 3, 2), 0, 1500);
|
||||
expect(d.plan!.slices.map((s) => s.amount.amount)).toEqual([1500, 1000, 1000]);
|
||||
});
|
||||
|
||||
test("planBalance reports the difference between the slices and the total", () => {
|
||||
const even = withPlan(draft({ amount: 3000 }), "2026-10", 3);
|
||||
expect(planBalance(even)).toBe(0);
|
||||
expect(planBalance(setSliceAmount(even, 0, 1500))).toBe(-500);
|
||||
const even = withPlan(draft({ amount: 3000 }), "2026-10", 3, 2);
|
||||
expect(planBalance(even, 2)).toBe(0);
|
||||
expect(planBalance(setSliceAmount(even, 0, 1500), 2)).toBe(-500);
|
||||
});
|
||||
|
||||
test("planBalance is zero when there is no plan", () => {
|
||||
expect(planBalance(draft())).toBe(0);
|
||||
expect(planBalance(draft(), 2)).toBe(0);
|
||||
});
|
||||
|
||||
test("a plan with unbalanced slices is still valid", () => {
|
||||
// A deposit-then-instalments plan may legitimately not sum to the total
|
||||
// yet while the user is typing; the editor warns rather than blocking.
|
||||
const d = setSliceAmount(withPlan(draft({ amount: 3000 }), "2026-10", 3), 0, 1500);
|
||||
const d = setSliceAmount(withPlan(draft({ amount: 3000 }), "2026-10", 3, 2), 0, 1500);
|
||||
expect(validatePurchase(d)).toEqual([]);
|
||||
});
|
||||
|
||||
test("a plan with zero months is rejected", () => {
|
||||
expect(() => withPlan(draft(), "2026-10", 0)).toThrow(/at least one month/i);
|
||||
expect(() => withPlan(draft(), "2026-10", 0, 2)).toThrow(/at least one month/i);
|
||||
});
|
||||
|
||||
test("split×plan composition: each slice divides across posts in the split ratio", () => {
|
||||
@@ -189,6 +189,7 @@ describe("finance plans", () => {
|
||||
}),
|
||||
"2026-10",
|
||||
3,
|
||||
2,
|
||||
);
|
||||
expect(composed.splits).toEqual([
|
||||
{ postId: "games", value: 70, absorbsRemainder: true },
|
||||
|
||||
+15
-5
@@ -45,10 +45,10 @@ export function emptyDraft(monthId: MonthId, postId: string): PurchaseDraft {
|
||||
}
|
||||
|
||||
/** What the split editor must show: how far the parts are from the whole. */
|
||||
export function splitBalance(draft: PurchaseDraft): number {
|
||||
export function splitBalance(draft: PurchaseDraft, digits: number): number {
|
||||
const target = draft.splitMode === "percent" ? 100 : draft.amount;
|
||||
const sum = draft.splits.reduce((total, split) => total + split.value, 0);
|
||||
return roundMoney(target - sum, draft.currency);
|
||||
return roundMoney(target - sum, digits);
|
||||
}
|
||||
|
||||
export function validatePurchase(draft: PurchaseDraft): string[] {
|
||||
@@ -104,12 +104,18 @@ export function withPlan(
|
||||
draft: PurchaseDraft,
|
||||
startMonth: MonthId,
|
||||
months: number,
|
||||
digits: number,
|
||||
): PurchaseDraft {
|
||||
return {
|
||||
...draft,
|
||||
plan: {
|
||||
startMonth,
|
||||
slices: equalSlices({ amount: draft.amount, currency: draft.currency }, startMonth, months),
|
||||
slices: equalSlices(
|
||||
{ amount: draft.amount, currency: draft.currency },
|
||||
startMonth,
|
||||
months,
|
||||
digits,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -140,9 +146,13 @@ export function setSliceAmount(
|
||||
* slices and compare to the total" arithmetic has exactly one implementation.
|
||||
* Positive means the slices fall short of the purchase total.
|
||||
*/
|
||||
export function planBalance(draft: PurchaseDraft): number {
|
||||
export function planBalance(draft: PurchaseDraft, digits: number): number {
|
||||
if (!draft.plan) return 0;
|
||||
return slicesBalance({ amount: draft.amount, currency: draft.currency }, draft.plan.slices);
|
||||
return slicesBalance(
|
||||
{ amount: draft.amount, currency: draft.currency },
|
||||
draft.plan.slices,
|
||||
digits,
|
||||
);
|
||||
}
|
||||
|
||||
export function fromPurchase(purchase: Purchase): PurchaseDraft {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { test, expect, describe } from "bun:test";
|
||||
import { groupPurchasesByDate } from "./purchaseGroups.ts";
|
||||
import type { Purchase } from "../domain/types.ts";
|
||||
|
||||
function purchase(id: string, date: string): Purchase {
|
||||
return {
|
||||
id,
|
||||
date,
|
||||
description: id,
|
||||
total: { amount: 100, currency: "DKK" },
|
||||
splitMode: "percent",
|
||||
splits: [{ postId: "p", value: 100, absorbsRemainder: true }],
|
||||
schedule: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("grouping a month's purchases by day", () => {
|
||||
test("newest day first, and purchases keep their order within a day", () => {
|
||||
const groups = groupPurchasesByDate(
|
||||
[purchase("a", "2026-09-03"), purchase("b", "2026-09-12"), purchase("c", "2026-09-03")],
|
||||
"2026-09",
|
||||
);
|
||||
expect(groups.map((g) => g.key)).toEqual(["2026-09-12", "2026-09-03"]);
|
||||
expect(groups[1]!.purchases.map((p) => p.id)).toEqual(["a", "c"]);
|
||||
});
|
||||
|
||||
test("the heading names the weekday, which is what a person recognises", () => {
|
||||
const [group] = groupPurchasesByDate([purchase("a", "2026-09-12")], "2026-09");
|
||||
// 2026-09-12 is a Saturday.
|
||||
expect(group!.label).toBe("Sat 12 September");
|
||||
});
|
||||
|
||||
/**
|
||||
* The weekday must not depend on where the machine is. Parsing
|
||||
* "2026-09-01" gives UTC midnight, so a getDay() in any negative-offset
|
||||
* zone reports the previous day — every heading would be wrong by one for
|
||||
* users west of Greenwich.
|
||||
*/
|
||||
test("the weekday is timezone-independent", () => {
|
||||
const label = (tz: string) => {
|
||||
const original = process.env.TZ;
|
||||
process.env.TZ = tz;
|
||||
try {
|
||||
return groupPurchasesByDate([purchase("a", "2026-09-01")], "2026-09")[0]!.label;
|
||||
} finally {
|
||||
process.env.TZ = original;
|
||||
}
|
||||
};
|
||||
expect(label("UTC")).toBe("Tue 1 September");
|
||||
expect(label("America/Los_Angeles")).toBe("Tue 1 September");
|
||||
expect(label("Pacific/Kiritimati")).toBe("Tue 1 September");
|
||||
});
|
||||
|
||||
/**
|
||||
* A finance plan's slices land in months after the purchase date, so in
|
||||
* those months the row's own date is not in view. Filing it under its real
|
||||
* date would put "14 August" inside the September list.
|
||||
*/
|
||||
test("a slice from an earlier month is grouped apart, and last", () => {
|
||||
const groups = groupPurchasesByDate(
|
||||
[purchase("bike", "2026-08-14"), purchase("rent", "2026-09-01")],
|
||||
"2026-09",
|
||||
);
|
||||
expect(groups.map((g) => g.key)).toEqual(["2026-09-01", "carried"]);
|
||||
expect(groups[1]!.label).toBe("Financed from earlier months");
|
||||
expect(groups[1]!.purchases.map((p) => p.id)).toEqual(["bike"]);
|
||||
});
|
||||
|
||||
/** A purchase date may legally be "YYYY-MM" with no day at all. */
|
||||
test("a purchase with no day sits in its own group, after the dated ones", () => {
|
||||
const groups = groupPurchasesByDate(
|
||||
[purchase("vague", "2026-09"), purchase("rent", "2026-09-01")],
|
||||
"2026-09",
|
||||
);
|
||||
expect(groups.map((g) => g.key)).toEqual(["2026-09-01", "undated"]);
|
||||
expect(groups[1]!.label).toBe("No day recorded");
|
||||
});
|
||||
|
||||
test("an empty month is an empty list, not a group of nothing", () => {
|
||||
expect(groupPurchasesByDate([], "2026-09")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { MonthId, Purchase } from "../domain/types.ts";
|
||||
|
||||
export interface PurchaseGroup {
|
||||
/** An ISO date, or the sentinel "carried" / "undated". */
|
||||
key: string;
|
||||
/** "Sat 12 September", or a sentence for the two sentinel groups. */
|
||||
label: string;
|
||||
purchases: Purchase[];
|
||||
}
|
||||
|
||||
const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
const MONTHS = [
|
||||
"January", "February", "March", "April", "May", "June",
|
||||
"July", "August", "September", "October", "November", "December",
|
||||
];
|
||||
|
||||
/**
|
||||
* Sorted last, in this order, after every dated group:
|
||||
* - `carried`: a finance plan's later slices. The row belongs in this month
|
||||
* because money moves this month, but its own date does not, so filing it
|
||||
* under that date would put "14 August" inside the September list.
|
||||
* - `undated`: a purchase date may legally be "YYYY-MM" with no day.
|
||||
*/
|
||||
const CARRIED = "carried";
|
||||
const UNDATED = "undated";
|
||||
|
||||
/**
|
||||
* Weekday without a timezone. `new Date("2026-09-01")` is UTC midnight, so
|
||||
* `getDay()` anywhere west of Greenwich reports the previous day and every
|
||||
* heading would be off by one. Building the date in UTC and reading it back in
|
||||
* UTC is the whole fix.
|
||||
*/
|
||||
function weekday(year: number, month: number, day: number): string {
|
||||
return WEEKDAYS[new Date(Date.UTC(year, month - 1, day)).getUTCDay()]!;
|
||||
}
|
||||
|
||||
function dayLabel(date: string): string {
|
||||
const [year, month, day] = date.split("-").map(Number) as [number, number, number];
|
||||
return `${weekday(year, month, day)} ${day} ${MONTHS[month - 1]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups a month's purchase rows under date headings, most recent day first.
|
||||
* Order within a day is preserved, so a row just entered stays where it was
|
||||
* put rather than jumping.
|
||||
*
|
||||
* Takes the rows already filtered for the month — deciding WHICH purchases
|
||||
* touch a month is `sliceAmountForMonth`'s job, and this must not second-guess
|
||||
* it.
|
||||
*/
|
||||
export function groupPurchasesByDate(
|
||||
purchases: Purchase[],
|
||||
monthId: MonthId,
|
||||
): PurchaseGroup[] {
|
||||
const byKey = new Map<string, Purchase[]>();
|
||||
|
||||
for (const purchase of purchases) {
|
||||
const hasDay = /^\d{4}-\d{2}-\d{2}$/.test(purchase.date);
|
||||
const key = !purchase.date.startsWith(monthId)
|
||||
? CARRIED
|
||||
: hasDay
|
||||
? purchase.date
|
||||
: UNDATED;
|
||||
const bucket = byKey.get(key);
|
||||
if (bucket) bucket.push(purchase);
|
||||
else byKey.set(key, [purchase]);
|
||||
}
|
||||
|
||||
const dated = [...byKey.keys()]
|
||||
.filter((key) => key !== CARRIED && key !== UNDATED)
|
||||
.sort()
|
||||
.reverse();
|
||||
|
||||
const order = [
|
||||
...dated,
|
||||
...(byKey.has(UNDATED) ? [UNDATED] : []),
|
||||
...(byKey.has(CARRIED) ? [CARRIED] : []),
|
||||
];
|
||||
|
||||
return order.map((key) => ({
|
||||
key,
|
||||
label:
|
||||
key === CARRIED
|
||||
? "Financed from earlier months"
|
||||
: key === UNDATED
|
||||
? "No day recorded"
|
||||
: dayLabel(key),
|
||||
purchases: byKey.get(key)!,
|
||||
}));
|
||||
}
|
||||
+27
-2
@@ -1,4 +1,5 @@
|
||||
import { BASE_PATH, withBase } from "./basePath.ts";
|
||||
import { requestUpdate } from "./swUpdate.ts";
|
||||
|
||||
let waiting: ServiceWorker | null = null;
|
||||
|
||||
@@ -32,8 +33,32 @@ export function registerServiceWorker(onUpdateReady: () => void): void {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reloads only once the new worker is actually in control. Posting
|
||||
* SKIP_WAITING and reloading on the next line raced activation, and when the
|
||||
* reload won, the reloaded page still found a waiting worker and showed the
|
||||
* update prompt again — forever. See src/ui/swUpdate.ts for the measurement.
|
||||
*/
|
||||
export function applyUpdate(): void {
|
||||
waiting?.postMessage({ type: "SKIP_WAITING" });
|
||||
const target = waiting;
|
||||
waiting = null;
|
||||
window.location.reload();
|
||||
|
||||
if (!target) {
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
|
||||
requestUpdate({
|
||||
postSkipWaiting: () => target.postMessage({ type: "SKIP_WAITING" }),
|
||||
onControllerChange: (listener) => {
|
||||
navigator.serviceWorker.addEventListener("controllerchange", listener);
|
||||
return () =>
|
||||
navigator.serviceWorker.removeEventListener("controllerchange", listener);
|
||||
},
|
||||
setTimer: (fn, ms) => {
|
||||
const id = setTimeout(fn, ms);
|
||||
return () => clearTimeout(id);
|
||||
},
|
||||
reload: () => window.location.reload(),
|
||||
});
|
||||
}
|
||||
+156
-91
@@ -9,11 +9,13 @@ import { monthView } from "../../domain/views.ts";
|
||||
import { addMonths } from "../../domain/months.ts";
|
||||
import { setIncome, deletePurchase, setRuleFrom } from "../../store/actions.ts";
|
||||
import { ruleAt } from "../../domain/allocation.ts";
|
||||
import type { MonthId, Post, Rule } from "../../domain/types.ts";
|
||||
import type { MonthId, Post, Purchase, Rule } from "../../domain/types.ts";
|
||||
import { sliceAmountForMonth } from "../../domain/charges.ts";
|
||||
import { formatMoney, formatSignedMoney } from "../format.ts";
|
||||
import { PostTable } from "../components/PostTable.tsx";
|
||||
import { PurchaseDialog } from "../components/PurchaseDialog.tsx";
|
||||
import { Section, Stat } from "../components/Section.tsx";
|
||||
import { groupPurchasesByDate } from "../purchaseGroups.ts";
|
||||
|
||||
export function MonthRoute() {
|
||||
const { monthId = "" } = useParams();
|
||||
@@ -22,61 +24,62 @@ export function MonthRoute() {
|
||||
const view = monthView(dataset, monthId);
|
||||
const base = dataset.settings.baseCurrency;
|
||||
const [changingRuleFor, setChangingRuleFor] = useState<string | null>(null);
|
||||
const monthPurchases = dataset.purchases.filter(
|
||||
(purchase) => sliceAmountForMonth(purchase, monthId) !== null,
|
||||
);
|
||||
const groups = groupPurchasesByDate(monthPurchases, monthId);
|
||||
const changingPost = changingRuleFor
|
||||
? (dataset.posts.find((p) => p.id === changingRuleFor) ?? null)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<div className="space-y-5">
|
||||
{error && (
|
||||
<div className="flex items-center justify-between rounded border border-destructive p-3 text-sm">
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-destructive/40 bg-destructive/5 p-3 text-sm"
|
||||
>
|
||||
<span>Could not save: {error}</span>
|
||||
<button onClick={clearError} className="underline">
|
||||
<button onClick={clearError} className="underline underline-offset-2">
|
||||
dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<header className="flex items-center gap-4">
|
||||
<Link to={`/month/${addMonths(monthId, -1)}`} className="text-sm hover:underline">
|
||||
← {addMonths(monthId, -1)}
|
||||
</Link>
|
||||
<h1 className="text-2xl font-semibold">{monthId}</h1>
|
||||
<Link to={`/month/${addMonths(monthId, 1)}`} className="text-sm hover:underline">
|
||||
{addMonths(monthId, 1)} →
|
||||
</Link>
|
||||
{/* The month is the app's primary coordinate, so it gets the largest
|
||||
type on the page and the two ways of moving sit either side of it. */}
|
||||
<header className="flex items-baseline gap-3">
|
||||
<MonthStep to={`/month/${addMonths(monthId, -1)}`} label={addMonths(monthId, -1)} back />
|
||||
<h1 className="text-2xl">{monthLabel(monthId)}</h1>
|
||||
<MonthStep to={`/month/${addMonths(monthId, 1)}`} label={addMonths(monthId, 1)} />
|
||||
</header>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-6">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="income">Income this month ({base})</Label>
|
||||
<Input
|
||||
id="income"
|
||||
type="number"
|
||||
step="0.01"
|
||||
className="font-money w-40"
|
||||
value={view.income}
|
||||
onChange={(event) => {
|
||||
// Read the DOM value NOW: mutate() defers behind the write queue,
|
||||
// by which time React has reset this input to the committed value.
|
||||
const amount = Number(event.target.value) || 0;
|
||||
mutate((draft) => setIncome(draft, monthId, { amount, currency: base }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<dl className="flex gap-6 text-sm">
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Allocated</dt>
|
||||
<dd className="font-money">{formatMoney(view.totalAllocation, base)}</dd>
|
||||
<Section>
|
||||
<div className="flex flex-wrap items-end gap-x-10 gap-y-5">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="income" className="text-[0.6875rem] font-medium uppercase tracking-wider text-budget-ink-muted">
|
||||
Income this month ({base})
|
||||
</Label>
|
||||
<Input
|
||||
id="income"
|
||||
type="number"
|
||||
step="0.01"
|
||||
className="font-money h-10 w-44 text-lg"
|
||||
value={view.income}
|
||||
onChange={(event) => {
|
||||
// Read the DOM value NOW: mutate() defers behind the write queue,
|
||||
// by which time React has reset this input to the committed value.
|
||||
const amount = Number(event.target.value) || 0;
|
||||
mutate((draft) => setIncome(draft, monthId, { amount, currency: base }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Spent</dt>
|
||||
<dd className="font-money">{formatMoney(view.totalCharges, base)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Unallocated</dt>
|
||||
<dd
|
||||
className={`font-money ${view.unallocated < 0 ? "text-overspend" : ""}`}
|
||||
<dl className="flex flex-wrap gap-x-10 gap-y-4">
|
||||
<Stat label="Allocated">{formatMoney(view.totalAllocation, base)}</Stat>
|
||||
<Stat label="Spent">{formatMoney(view.totalCharges, base)}</Stat>
|
||||
<Stat
|
||||
label="Unallocated"
|
||||
tone={view.unallocated < 0 ? "overspend" : "default"}
|
||||
title={
|
||||
view.unallocated < 0
|
||||
? "Allocations exceed this month's income. This is allowed."
|
||||
@@ -84,17 +87,19 @@ export function MonthRoute() {
|
||||
}
|
||||
>
|
||||
{formatSignedMoney(view.unallocated, base)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</Stat>
|
||||
</dl>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Posts" className="overflow-hidden">
|
||||
<PostTable
|
||||
monthId={monthId}
|
||||
baseCurrency={base}
|
||||
rows={view.rows}
|
||||
onChangeRule={setChangingRuleFor}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{changingPost && (
|
||||
<RuleFromMonth
|
||||
@@ -109,52 +114,112 @@ export function MonthRoute() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-medium">Purchases</h2>
|
||||
<PurchaseDialog monthId={monthId} trigger={<Button>Add purchase</Button>} />
|
||||
</div>
|
||||
<ul className="divide-y text-sm">
|
||||
{dataset.purchases
|
||||
.filter((purchase) => sliceAmountForMonth(purchase, monthId) !== null)
|
||||
.map((purchase) => {
|
||||
const slice = sliceAmountForMonth(purchase, monthId)!;
|
||||
return (
|
||||
<li key={purchase.id} className="flex items-center gap-3 py-2">
|
||||
<span className="flex-1">
|
||||
{purchase.description}
|
||||
{purchase.note && (
|
||||
<span className="ml-2 text-xs text-muted-foreground">{purchase.note}</span>
|
||||
)}
|
||||
</span>
|
||||
{purchase.schedule && (
|
||||
<span className="text-xs text-muted-foreground">financed</span>
|
||||
)}
|
||||
<span className="font-money">
|
||||
{formatMoney(slice.amount, slice.currency)}
|
||||
</span>
|
||||
<PurchaseDialog
|
||||
monthId={monthId}
|
||||
purchase={purchase}
|
||||
trigger={
|
||||
<Button size="sm" variant="ghost">
|
||||
edit
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => mutate((data) => deletePurchase(data, purchase.id))}
|
||||
>
|
||||
delete
|
||||
</Button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
</section>
|
||||
<Section
|
||||
title="Purchases"
|
||||
action={<PurchaseDialog monthId={monthId} trigger={<Button>Add purchase</Button>} />}
|
||||
>
|
||||
{groups.length === 0 ? (
|
||||
<p className="py-1 text-sm text-budget-ink-muted">
|
||||
Nothing recorded for this month yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3.5">
|
||||
{groups.map((group) => (
|
||||
<div key={group.key}>
|
||||
<h3 className="mb-0.5 text-[0.6875rem] font-medium uppercase tracking-wider text-budget-ink-muted">
|
||||
{group.label}
|
||||
</h3>
|
||||
<ul className="divide-y divide-budget-rule text-sm">
|
||||
{group.purchases.map((purchase) => (
|
||||
<PurchaseRow
|
||||
key={purchase.id}
|
||||
purchase={purchase}
|
||||
monthId={monthId}
|
||||
onDelete={() =>
|
||||
mutate((data) => deletePurchase(data, purchase.id))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PurchaseRow({
|
||||
purchase,
|
||||
monthId,
|
||||
onDelete,
|
||||
}: {
|
||||
purchase: Purchase;
|
||||
monthId: MonthId;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const slice = sliceAmountForMonth(purchase, monthId)!;
|
||||
return (
|
||||
<li className="group flex items-center gap-3 rounded-md px-1 py-1.5 transition-colors hover:bg-accent/60">
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{purchase.description}
|
||||
{purchase.note && (
|
||||
<span className="ml-2 text-xs text-budget-ink-muted">{purchase.note}</span>
|
||||
)}
|
||||
</span>
|
||||
{purchase.schedule && (
|
||||
<span className="shrink-0 rounded-full border border-budget-rule px-1.5 py-px text-[0.625rem] uppercase tracking-wide text-budget-ink-muted">
|
||||
financed
|
||||
</span>
|
||||
)}
|
||||
<span className="font-money shrink-0 tabular-nums">
|
||||
{formatMoney(slice.amount, slice.currency)}
|
||||
</span>
|
||||
{/* Held at a fixed width so revealing the actions on hover cannot shift
|
||||
the amounts, which are the column being read down. */}
|
||||
<span className="flex w-[7.5rem] shrink-0 justify-end gap-1 opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100">
|
||||
<PurchaseDialog
|
||||
monthId={monthId}
|
||||
purchase={purchase}
|
||||
trigger={
|
||||
<Button size="sm" variant="ghost">
|
||||
edit
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-overspend hover:text-overspend"
|
||||
onClick={onDelete}
|
||||
>
|
||||
delete
|
||||
</Button>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** "2026-09" reads as a key; "September 2026" reads as a month. */
|
||||
function monthLabel(monthId: MonthId): string {
|
||||
const [year, month] = monthId.split("-");
|
||||
const names = [
|
||||
"January", "February", "March", "April", "May", "June",
|
||||
"July", "August", "September", "October", "November", "December",
|
||||
];
|
||||
const name = names[Number(month) - 1];
|
||||
return name ? `${name} ${year}` : monthId;
|
||||
}
|
||||
|
||||
function MonthStep({ to, label, back = false }: { to: string; label: string; back?: boolean }) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className="font-money rounded-md px-1.5 py-0.5 text-xs text-budget-ink-muted transition-colors hover:bg-accent hover:text-budget-ink"
|
||||
>
|
||||
{back ? `\u2190 ${label}` : `${label} \u2192`}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,13 @@ export function PostMonthRoute() {
|
||||
const row = monthView(dataset, monthId).rows.find((r) => r.post.id === postId);
|
||||
|
||||
const thisMonth = dataset.purchases.flatMap((purchase) => {
|
||||
const charges = chargesForPurchaseInMonth(purchase, monthId, base, dataset.fxRates);
|
||||
const charges = chargesForPurchaseInMonth(
|
||||
purchase,
|
||||
monthId,
|
||||
base,
|
||||
dataset.fxRates,
|
||||
dataset.currencies,
|
||||
);
|
||||
const mine = charges.find((c) => c.postId === postId);
|
||||
return mine ? [{ purchase, amount: mine.amount }] : [];
|
||||
});
|
||||
|
||||
@@ -2,10 +2,16 @@ import { CurrencySection } from "./settings/CurrencySection.tsx";
|
||||
import { PostsSection } from "./settings/PostsSection.tsx";
|
||||
import { DataSection } from "./settings/DataSection.tsx";
|
||||
|
||||
/**
|
||||
* Each section is its own card. They are unrelated settings — money, posts,
|
||||
* and the export/import of everything — and running them together down one
|
||||
* column, as before, made the destructive import controls look like a
|
||||
* continuation of the harmless ones.
|
||||
*/
|
||||
export function SettingsRoute() {
|
||||
return (
|
||||
<div className="max-w-4xl space-y-10">
|
||||
<h1 className="text-2xl font-semibold">Settings</h1>
|
||||
<div className="max-w-4xl space-y-5">
|
||||
<h1 className="text-2xl">Settings</h1>
|
||||
<CurrencySection />
|
||||
<PostsSection />
|
||||
<DataSection />
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { useDataset } from "../hooks/useDataset.ts";
|
||||
import { datasetMonthSpan, summaryView } from "../../domain/views.ts";
|
||||
import { formatAmount, formatMoney } from "../format.ts";
|
||||
import { Section, Stat } from "../components/Section.tsx";
|
||||
|
||||
export function SummaryRoute() {
|
||||
const dataset = useDataset();
|
||||
@@ -19,9 +20,10 @@ export function SummaryRoute() {
|
||||
const difference = Number((view.totalIncome - view.totalCharges).toFixed(2));
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<h1 className="text-2xl font-semibold">Summary</h1>
|
||||
<div className="space-y-5">
|
||||
<h1 className="text-2xl">Summary</h1>
|
||||
|
||||
<Section>
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="from">From</Label>
|
||||
@@ -41,52 +43,49 @@ export function SummaryRoute() {
|
||||
>
|
||||
All time
|
||||
</Button>
|
||||
<div className="ml-auto flex gap-1">
|
||||
{(["post", "month"] as const).map((option) => (
|
||||
<Button
|
||||
key={option}
|
||||
size="sm"
|
||||
variant={groupBy === option ? "default" : "outline"}
|
||||
onClick={() => setGroupBy(option)}
|
||||
>
|
||||
by {option}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl className="flex gap-6 text-sm">
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Income</dt>
|
||||
<dd className="font-money">{formatMoney(view.totalIncome, base)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Spent</dt>
|
||||
<dd className="font-money">{formatMoney(view.totalCharges, base)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Difference</dt>
|
||||
<dd className={`font-money ${difference < 0 ? "text-overspend" : ""}`}>
|
||||
{formatMoney(difference, base)}
|
||||
</dd>
|
||||
</div>
|
||||
<dl className="mt-5 flex flex-wrap gap-x-10 gap-y-4 border-t border-budget-rule pt-5">
|
||||
<Stat label="Income">{formatMoney(view.totalIncome, base)}</Stat>
|
||||
<Stat label="Spent">{formatMoney(view.totalCharges, base)}</Stat>
|
||||
<Stat label="Difference" tone={difference < 0 ? "overspend" : "default"}>
|
||||
{formatMoney(difference, base)}
|
||||
</Stat>
|
||||
</dl>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title={groupBy === "post" ? "By post" : "By month"}
|
||||
action={
|
||||
<div className="flex gap-1">
|
||||
{(["post", "month"] as const).map((option) => (
|
||||
<Button
|
||||
key={option}
|
||||
size="sm"
|
||||
variant={groupBy === option ? "default" : "outline"}
|
||||
onClick={() => setGroupBy(option)}
|
||||
>
|
||||
by {option}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b text-left text-muted-foreground">
|
||||
<tr>
|
||||
<th className="py-2">{groupBy === "post" ? "Post" : "Month"}</th>
|
||||
<th className="py-2 text-right">Spent</th>
|
||||
<th className="py-2 text-right">Share</th>
|
||||
<thead className="text-left">
|
||||
<tr className="border-b border-budget-rule text-[0.6875rem] uppercase tracking-wider text-budget-ink-muted">
|
||||
<th className="py-2 font-medium">{groupBy === "post" ? "Post" : "Month"}</th>
|
||||
<th className="py-2 pl-6 text-right font-medium">Spent</th>
|
||||
<th className="py-2 pl-6 text-right font-medium">Share</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{groupBy === "post"
|
||||
? view.byPost.map((entry) => (
|
||||
<tr key={entry.post.id} className="border-b last:border-0">
|
||||
<td className="py-2">{entry.post.name}</td>
|
||||
<td className="py-2 text-right font-money">{formatAmount(entry.charges)}</td>
|
||||
<td className="py-2 text-right font-money text-muted-foreground">
|
||||
<tr key={entry.post.id} className="border-b border-budget-rule transition-colors last:border-0 hover:bg-accent/60">
|
||||
<td className="py-2.5">{entry.post.name}</td>
|
||||
<td className="font-money py-2.5 pl-6 text-right">{formatAmount(entry.charges)}</td>
|
||||
<td className="font-money py-2.5 pl-6 text-right text-budget-ink-muted">
|
||||
{view.totalCharges === 0
|
||||
? "—"
|
||||
: `${((entry.charges / view.totalCharges) * 100).toFixed(1)}%`}
|
||||
@@ -94,14 +93,14 @@ export function SummaryRoute() {
|
||||
</tr>
|
||||
))
|
||||
: view.byMonth.map((entry) => (
|
||||
<tr key={entry.monthId} className="border-b last:border-0">
|
||||
<tr key={entry.monthId} className="border-b border-budget-rule transition-colors last:border-0 hover:bg-accent/60">
|
||||
<td className="py-2">
|
||||
<Link to={`/month/${entry.monthId}`} className="hover:underline">
|
||||
{entry.monthId}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-2 text-right font-money">{formatAmount(entry.charges)}</td>
|
||||
<td className="py-2 text-right font-money text-muted-foreground">
|
||||
<td className="font-money py-2.5 pl-6 text-right">{formatAmount(entry.charges)}</td>
|
||||
<td className="font-money py-2.5 pl-6 text-right text-budget-ink-muted">
|
||||
{view.totalCharges === 0
|
||||
? "—"
|
||||
: `${((entry.charges / view.totalCharges) * 100).toFixed(1)}%`}
|
||||
@@ -110,6 +109,7 @@ export function SummaryRoute() {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+38
-23
@@ -5,6 +5,7 @@ import { useDataset } from "../hooks/useDataset.ts";
|
||||
import { yearView } from "../../domain/views.ts";
|
||||
import { formatMoney } from "../format.ts";
|
||||
import { YearMatrix } from "../components/YearMatrix.tsx";
|
||||
import { Section, Stat } from "../components/Section.tsx";
|
||||
|
||||
export function YearRoute() {
|
||||
const { year = "" } = useParams();
|
||||
@@ -15,35 +16,49 @@ export function YearRoute() {
|
||||
const base = dataset.settings.baseCurrency;
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<header className="flex items-center gap-4">
|
||||
<Link to={`/year/${numericYear - 1}`} className="text-sm hover:underline">
|
||||
<div className="space-y-5">
|
||||
<header className="flex items-baseline gap-3">
|
||||
<Link
|
||||
to={`/year/${numericYear - 1}`}
|
||||
className="font-money rounded-md px-1.5 py-0.5 text-xs text-budget-ink-muted transition-colors hover:bg-accent hover:text-budget-ink"
|
||||
>
|
||||
← {numericYear - 1}
|
||||
</Link>
|
||||
<h1 className="text-2xl font-semibold">{year}</h1>
|
||||
<Link to={`/year/${numericYear + 1}`} className="text-sm hover:underline">
|
||||
<h1 className="text-2xl">{year}</h1>
|
||||
<Link
|
||||
to={`/year/${numericYear + 1}`}
|
||||
className="font-money rounded-md px-1.5 py-0.5 text-xs text-budget-ink-muted transition-colors hover:bg-accent hover:text-budget-ink"
|
||||
>
|
||||
{numericYear + 1} →
|
||||
</Link>
|
||||
<div className="ml-auto flex gap-1">
|
||||
{(["spend", "balance"] as const).map((option) => (
|
||||
<Button
|
||||
key={option}
|
||||
size="sm"
|
||||
variant={mode === option ? "default" : "outline"}
|
||||
onClick={() => setMode(option)}
|
||||
>
|
||||
{option === "spend" ? "Spent" : "Closing balance"}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Income {formatMoney(view.totalIncome, base)} · spent{" "}
|
||||
{formatMoney(view.totalCharges, base)}
|
||||
</p>
|
||||
<Section>
|
||||
<dl className="flex flex-wrap gap-x-10 gap-y-4">
|
||||
<Stat label="Income">{formatMoney(view.totalIncome, base)}</Stat>
|
||||
<Stat label="Spent">{formatMoney(view.totalCharges, base)}</Stat>
|
||||
</dl>
|
||||
</Section>
|
||||
|
||||
<YearMatrix view={view} mode={mode} />
|
||||
</section>
|
||||
<Section
|
||||
title="Posts by month"
|
||||
action={
|
||||
<div className="flex gap-1">
|
||||
{(["spend", "balance"] as const).map((option) => (
|
||||
<Button
|
||||
key={option}
|
||||
size="sm"
|
||||
variant={mode === option ? "default" : "outline"}
|
||||
onClick={() => setMode(option)}
|
||||
>
|
||||
{option === "spend" ? "Spent" : "Closing balance"}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<YearMatrix view={view} mode={mode} />
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,21 @@
|
||||
import { useState } from "react";
|
||||
import { Section } from "../../components/Section.tsx";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useDataset } from "../../hooks/useDataset.ts";
|
||||
import { useMutate } from "../../hooks/useMutate.ts";
|
||||
import { setBaseCurrency, setFxRate, removeFxRate } from "../../../store/actions.ts";
|
||||
import {
|
||||
addCurrency,
|
||||
removeCurrency,
|
||||
setBaseCurrency,
|
||||
setFxRate,
|
||||
removeFxRate,
|
||||
updateCurrency,
|
||||
} from "../../../store/actions.ts";
|
||||
import { DEFAULT_FX_API_URL, fetchFxRates } from "../../../store/fxApi.ts";
|
||||
import { CURRENCIES, type Currency } from "../../../domain/types.ts";
|
||||
import { currencyUsage } from "../../../domain/currencies.ts";
|
||||
import type { Currency } from "../../../domain/types.ts";
|
||||
|
||||
export function CurrencySection() {
|
||||
const dataset = useDataset();
|
||||
@@ -14,7 +23,8 @@ export function CurrencySection() {
|
||||
const base = dataset.settings.baseCurrency;
|
||||
const apiUrl = dataset.settings.fxApiUrl ?? DEFAULT_FX_API_URL;
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const targets = CURRENCIES.filter((c) => c !== base);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const targets = dataset.currencies.map((c) => c.code).filter((c) => c !== base);
|
||||
|
||||
async function refresh() {
|
||||
setStatus("Fetching…");
|
||||
@@ -23,95 +33,198 @@ export function CurrencySection() {
|
||||
mutate((draft) => {
|
||||
for (const rate of rates) setFxRate(draft, rate);
|
||||
});
|
||||
setStatus(`Updated ${rates.length} rate(s).`);
|
||||
} catch (error) {
|
||||
const missing = targets.filter((t) => !rates.some((r) => r.currency === t));
|
||||
setStatus(
|
||||
`Updated ${rates.length} rate(s).` +
|
||||
(missing.length > 0
|
||||
? ` The service returned nothing for ${missing.join(", ")} — type those in by hand.`
|
||||
: ""),
|
||||
);
|
||||
} catch (cause) {
|
||||
// Fails soft: the cached rates are still in place, never cleared.
|
||||
setStatus(
|
||||
`Could not fetch rates (${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
cause instanceof Error ? cause.message : String(cause)
|
||||
}). Your saved rates are unchanged.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-medium">Currency</h2>
|
||||
/** Currency edits throw on bad input, and a throw from a handler is invisible. */
|
||||
function tryEdit(change: () => void) {
|
||||
try {
|
||||
setError(null);
|
||||
change();
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : String(cause));
|
||||
}
|
||||
}
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="base-currency">Base currency (all totals use this)</Label>
|
||||
return (
|
||||
<Section
|
||||
title="Currencies"
|
||||
hint="Add any currency you spend in. The code is how it is identified, so it cannot be changed later — and everything is converted to the base currency for totals."
|
||||
>
|
||||
{error && <p className="mb-3 text-sm text-destructive">{error}</p>}
|
||||
|
||||
<div className="mb-5 space-y-1.5">
|
||||
<Label
|
||||
htmlFor="base-currency"
|
||||
className="text-[0.6875rem] font-medium uppercase tracking-wider text-budget-ink-muted"
|
||||
>
|
||||
Base currency (all totals use this)
|
||||
</Label>
|
||||
<select
|
||||
id="base-currency"
|
||||
className="h-9 w-32 rounded border bg-background px-2 text-sm"
|
||||
className="h-9 w-40 rounded-md border border-input bg-background px-2 text-sm"
|
||||
value={base}
|
||||
onChange={(event) => {
|
||||
const currency = event.target.value as Currency;
|
||||
const currency = event.target.value;
|
||||
mutate((draft) => setBaseCurrency(draft, currency));
|
||||
}}
|
||||
>
|
||||
{CURRENCIES.map((currency) => (
|
||||
<option key={currency} value={currency}>
|
||||
{currency}
|
||||
{dataset.currencies.map(({ code }) => (
|
||||
<option key={code} value={code}>
|
||||
{code}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<table className="w-full max-w-md text-sm">
|
||||
<thead className="border-b text-left text-muted-foreground">
|
||||
<tr>
|
||||
<th className="py-2">Currency</th>
|
||||
<th className="py-2 text-right">1 unit = ? {base}</th>
|
||||
<th className="py-2 text-right">Source</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{targets.map((currency) => {
|
||||
const rate = dataset.fxRates.find((r) => r.currency === currency);
|
||||
return (
|
||||
<tr key={currency} className="border-b last:border-0">
|
||||
<td className="py-2">{currency}</td>
|
||||
<td className="py-2 text-right">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.000001"
|
||||
className="font-money ml-auto h-8 w-32"
|
||||
value={rate?.baseUnitsPerOne ?? ""}
|
||||
onChange={(event) => {
|
||||
// A cleared or non-positive field MUST remove the rate
|
||||
// row rather than store 0: toBase() converts a stored 0
|
||||
// silently to a 0-value conversion, while an absent row
|
||||
// correctly throws MissingRateError. Storing 0 here
|
||||
// would turn a loud, correct error into wrong numbers
|
||||
// presented as fact.
|
||||
const parsed = Number(event.target.value);
|
||||
mutate((draft) => {
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
setFxRate(draft, {
|
||||
currency,
|
||||
baseUnitsPerOne: parsed,
|
||||
updatedAt: new Date().toISOString(),
|
||||
source: "manual",
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[42rem] text-sm">
|
||||
<thead className="text-left">
|
||||
<tr className="border-b border-budget-rule text-[0.6875rem] uppercase tracking-wider text-budget-ink-muted">
|
||||
<th className="py-2 font-medium">Code</th>
|
||||
<th className="py-2 pl-4 font-medium">Name</th>
|
||||
<th className="py-2 pl-4 font-medium">Symbol</th>
|
||||
<th className="py-2 pl-4 text-right font-medium">Decimals</th>
|
||||
<th className="py-2 pl-4 text-right font-medium">1 unit = ? {base}</th>
|
||||
<th className="py-2 pl-4 text-right font-medium">Source</th>
|
||||
<th className="py-2 pl-4" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{dataset.currencies.map((currency) => {
|
||||
const isBase = currency.code === base;
|
||||
const rate = dataset.fxRates.find((r) => r.currency === currency.code);
|
||||
const used = currencyUsage(dataset, currency.code);
|
||||
return (
|
||||
<tr
|
||||
key={currency.code}
|
||||
className="group border-b border-budget-rule transition-colors last:border-0 hover:bg-accent/60"
|
||||
>
|
||||
<td className="font-money py-2 font-medium">{currency.code}</td>
|
||||
<td className="py-2 pl-4">
|
||||
<Input
|
||||
className="h-8 w-40"
|
||||
aria-label={`Name for ${currency.code}`}
|
||||
value={currency.name ?? ""}
|
||||
onChange={(event) => {
|
||||
const name = event.target.value;
|
||||
mutate((draft) => updateCurrency(draft, currency.code, { name }));
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pl-4">
|
||||
<Input
|
||||
className="font-money h-8 w-16"
|
||||
aria-label={`Symbol for ${currency.code}`}
|
||||
value={currency.symbol ?? ""}
|
||||
onChange={(event) => {
|
||||
const symbol = event.target.value;
|
||||
mutate((draft) => updateCurrency(draft, currency.code, { symbol }));
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pl-4 text-right">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="4"
|
||||
step="1"
|
||||
className="font-money ml-auto h-8 w-16 text-right"
|
||||
aria-label={`Decimal places for ${currency.code}`}
|
||||
value={currency.digits}
|
||||
onChange={(event) => {
|
||||
const digits = Number(event.target.value);
|
||||
if (!Number.isInteger(digits) || digits < 0 || digits > 4) return;
|
||||
mutate((draft) => updateCurrency(draft, currency.code, { digits }));
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pl-4 text-right">
|
||||
{isBase ? (
|
||||
<span className="font-money text-budget-ink-muted">1</span>
|
||||
) : (
|
||||
<Input
|
||||
type="number"
|
||||
step="0.000001"
|
||||
className="font-money ml-auto h-8 w-32 text-right"
|
||||
aria-label={`Rate for ${currency.code}`}
|
||||
value={rate?.baseUnitsPerOne ?? ""}
|
||||
onChange={(event) => {
|
||||
// A cleared or non-positive field MUST remove the rate
|
||||
// row rather than store 0: toBase() converts a stored 0
|
||||
// silently to a 0-value conversion, while an absent row
|
||||
// correctly throws MissingRateError. Storing 0 here
|
||||
// would turn a loud, correct error into wrong numbers
|
||||
// presented as fact.
|
||||
const parsed = Number(event.target.value);
|
||||
mutate((draft) => {
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
setFxRate(draft, {
|
||||
currency: currency.code,
|
||||
baseUnitsPerOne: parsed,
|
||||
updatedAt: new Date().toISOString(),
|
||||
source: "manual",
|
||||
});
|
||||
} else {
|
||||
removeFxRate(draft, currency.code);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
removeFxRate(draft, currency);
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 text-right text-xs text-muted-foreground">
|
||||
{rate ? `${rate.source} · ${rate.updatedAt.slice(0, 10)}` : "not set"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pl-4 text-right text-xs text-budget-ink-muted">
|
||||
{isBase ? "—" : rate ? `${rate.source} · ${rate.updatedAt.slice(0, 10)}` : "not set"}
|
||||
</td>
|
||||
<td className="py-2 pl-4 text-right">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-overspend hover:text-overspend disabled:opacity-40"
|
||||
disabled={used.length > 0}
|
||||
title={
|
||||
used.length > 0
|
||||
? `In use by ${used.slice(0, 3).join(", ")}${used.length > 3 ? ` and ${used.length - 3} more` : ""}`
|
||||
: `Remove ${currency.code}`
|
||||
}
|
||||
onClick={() =>
|
||||
tryEdit(() => mutate((draft) => removeCurrency(draft, currency.code)))
|
||||
}
|
||||
>
|
||||
remove
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="fx-url">Rate service URL</Label>
|
||||
<AddCurrency onAdd={(def) => tryEdit(() => mutate((draft) => addCurrency(draft, def)))} />
|
||||
|
||||
<div className="mt-5 space-y-2 border-t border-budget-rule pt-5">
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="fx-url"
|
||||
className="text-[0.6875rem] font-medium uppercase tracking-wider text-budget-ink-muted"
|
||||
>
|
||||
Rate service URL
|
||||
</Label>
|
||||
<Input
|
||||
id="fx-url"
|
||||
className="max-w-xl"
|
||||
@@ -123,16 +236,102 @@ export function CurrencySection() {
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-xs text-budget-ink-muted">
|
||||
{"{base}"} and {"{targets}"} are substituted. Fetching is optional — the app
|
||||
works offline with the rates you type in above.
|
||||
works offline with the rates you type in above, and a currency the service
|
||||
does not know can always be maintained by hand.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => void refresh()}>
|
||||
Fetch rates now
|
||||
</Button>
|
||||
{status && <p className="text-xs">{status}</p>}
|
||||
{status && <p className="text-xs text-budget-ink-muted">{status}</p>}
|
||||
</div>
|
||||
</section>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decimals default to 2 because almost every currency uses hundredths; the
|
||||
* field is there for the ones that do not (yen at 0, dinars at 3).
|
||||
*/
|
||||
function AddCurrency({ onAdd }: { onAdd: (def: { code: Currency; digits: number; symbol?: string; name?: string }) => void }) {
|
||||
const [code, setCode] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [symbol, setSymbol] = useState("");
|
||||
const [digits, setDigits] = useState("2");
|
||||
|
||||
const parsedDigits = Number(digits);
|
||||
const canAdd =
|
||||
/^[A-Za-z]{2,8}$/.test(code.trim()) &&
|
||||
Number.isInteger(parsedDigits) &&
|
||||
parsedDigits >= 0 &&
|
||||
parsedDigits <= 4;
|
||||
|
||||
function add() {
|
||||
onAdd({ code: code.trim(), digits: parsedDigits, symbol, name });
|
||||
setCode("");
|
||||
setName("");
|
||||
setSymbol("");
|
||||
setDigits("2");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-4 flex flex-wrap items-end gap-2">
|
||||
<Input
|
||||
className="font-money h-9 w-24 uppercase"
|
||||
placeholder="JPY"
|
||||
aria-label="New currency code"
|
||||
value={code}
|
||||
onChange={(event) => {
|
||||
const next = event.target.value;
|
||||
setCode(next);
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
className="h-9 w-44"
|
||||
placeholder="Japanese yen"
|
||||
aria-label="New currency name"
|
||||
value={name}
|
||||
onChange={(event) => {
|
||||
const next = event.target.value;
|
||||
setName(next);
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
className="font-money h-9 w-16"
|
||||
placeholder="¥"
|
||||
aria-label="New currency symbol"
|
||||
value={symbol}
|
||||
onChange={(event) => {
|
||||
const next = event.target.value;
|
||||
setSymbol(next);
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Label
|
||||
htmlFor="new-digits"
|
||||
className="text-[0.6875rem] font-medium uppercase tracking-wider text-budget-ink-muted"
|
||||
>
|
||||
Decimals
|
||||
</Label>
|
||||
<Input
|
||||
id="new-digits"
|
||||
type="number"
|
||||
min="0"
|
||||
max="4"
|
||||
step="1"
|
||||
className="font-money h-9 w-16 text-right"
|
||||
value={digits}
|
||||
onChange={(event) => {
|
||||
const next = event.target.value;
|
||||
setDigits(next);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" disabled={!canAdd} onClick={add}>
|
||||
Add currency
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "../../../export/json.ts";
|
||||
import { buildOds, odsFilename } from "../../../export/ods.ts";
|
||||
import type { Dataset } from "../../../domain/types.ts";
|
||||
import { Section } from "../../components/Section.tsx";
|
||||
|
||||
export function DataSection() {
|
||||
const dataset = useDataset();
|
||||
@@ -76,19 +77,27 @@ export function DataSection() {
|
||||
// replace, never after. If the backup itself fails, abort rather than
|
||||
// destroy the only copy of the user's data.
|
||||
if (!runExport(buildJsonExport)) return;
|
||||
await store.replace(pending);
|
||||
// The write itself can still fail — a full disk, evicted storage, a
|
||||
// private-mode quota. Unguarded it is an unhandled rejection: the button
|
||||
// appears to do nothing and the dialog stays open with no explanation.
|
||||
// `pending` is deliberately kept on failure so the user can retry.
|
||||
try {
|
||||
await store.replace(pending);
|
||||
} catch (cause) {
|
||||
const message = cause instanceof Error ? cause.message : String(cause);
|
||||
setError(`Could not import: ${message}. Your data has not been changed.`);
|
||||
return;
|
||||
}
|
||||
setPending(null);
|
||||
}
|
||||
|
||||
const counts = describeDataset(dataset);
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-medium">Your data</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Everything lives in this browser. Export regularly — it is both your
|
||||
backup and how you move your budget to another device.
|
||||
</p>
|
||||
<Section
|
||||
title="Your data"
|
||||
hint="Everything lives in this browser. Export regularly — it is both your backup and how you move your budget to another device."
|
||||
>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" onClick={exportJson}>
|
||||
@@ -138,6 +147,6 @@ export function DataSection() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -4,10 +4,11 @@ import { Input } from "@/components/ui/input";
|
||||
import { useDataset } from "../../hooks/useDataset.ts";
|
||||
import { useMutate } from "../../hooks/useMutate.ts";
|
||||
import { addPost, movePost, setPostArchived, updatePost } from "../../../store/actions.ts";
|
||||
import { CURRENCIES, type Currency, type Post } from "../../../domain/types.ts";
|
||||
import type { Currency, Post } from "../../../domain/types.ts";
|
||||
import { ruleAt } from "../../../domain/allocation.ts";
|
||||
import { currentMonth } from "../../../store/index.ts";
|
||||
import { RuleHistory } from "../../components/RuleHistory.tsx";
|
||||
import { Section } from "../../components/Section.tsx";
|
||||
|
||||
export function PostsSection() {
|
||||
const dataset = useDataset();
|
||||
@@ -34,22 +35,17 @@ export function PostsSection() {
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-medium">Posts</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
A post's allocation is a dated series: each rule applies from its own
|
||||
month onward until the next one takes over, so changing it never
|
||||
rewrites what earlier months got. Click a rule to see and edit that
|
||||
history. Any single month can still override its own allocation from
|
||||
the month view. Percentages may total more than 100%.
|
||||
</p>
|
||||
<Section
|
||||
title="Posts"
|
||||
hint="A post's allocation is a dated series: each rule applies from its own month onward until the next one takes over, so changing it never rewrites what earlier months got. Click a rule to see and edit that history. A single month can still override its own allocation from the month view, and percentages may total more than 100%."
|
||||
>
|
||||
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b text-left text-muted-foreground">
|
||||
<tr>
|
||||
<th className="py-2">Name</th>
|
||||
<th className="py-2">Display currency</th>
|
||||
<th className="py-2">Rule</th>
|
||||
<thead className="text-left">
|
||||
<tr className="border-b border-budget-rule text-[0.6875rem] uppercase tracking-wider text-budget-ink-muted">
|
||||
<th className="py-2 font-medium">Name</th>
|
||||
<th className="py-2 font-medium">Display currency</th>
|
||||
<th className="py-2 font-medium">Rule</th>
|
||||
<th className="py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -76,7 +72,7 @@ export function PostsSection() {
|
||||
mutate((draft) => updatePost(draft, post.id, { currency }));
|
||||
}}
|
||||
>
|
||||
{CURRENCIES.map((currency) => (
|
||||
{dataset.currencies.map(({ code: currency }) => (
|
||||
<option key={currency} value={currency}>
|
||||
{currency}
|
||||
</option>
|
||||
@@ -121,7 +117,7 @@ export function PostsSection() {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-xs text-budget-ink-muted">
|
||||
Archiving hides a post from new purchases while keeping its history. Posts
|
||||
are never deleted, because purchases reference them.
|
||||
</p>
|
||||
@@ -144,6 +140,6 @@ export function PostsSection() {
|
||||
Add post
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { Glob } from "bun";
|
||||
|
||||
/**
|
||||
* A store write is the only step that can fail after the user has committed to
|
||||
* an action, and IndexedDB fails for reasons no code can prevent — a full disk,
|
||||
* a browser evicting storage, private-mode quotas. An awaited `store.*` call
|
||||
* with no handler is an unhandled rejection: the button appears to do nothing
|
||||
* and the user is told nothing.
|
||||
*
|
||||
* `useMutate` exists so most call sites cannot make this mistake. This guards
|
||||
* the ones that talk to the store directly. Source-level rather than
|
||||
* behavioural, like eventCapture.test.ts, because reproducing it needs a real
|
||||
* failing IndexedDB.
|
||||
*/
|
||||
test("every awaited store write in the UI is guarded", async () => {
|
||||
const offenders: string[] = [];
|
||||
|
||||
for await (const file of new Glob("src/ui/**/*.{ts,tsx}").scan(".")) {
|
||||
if (file.endsWith(".test.ts") || file.endsWith(".test.tsx")) continue;
|
||||
// useMutate IS the guard — it wraps store.mutate in a .catch.
|
||||
if (file.endsWith("hooks/useMutate.ts")) continue;
|
||||
const source = await Bun.file(file).text();
|
||||
|
||||
for (const match of source.matchAll(/await\s+store\.\w+\(/g)) {
|
||||
const enclosing = enclosingFunction(source, match.index);
|
||||
if (!/try\s*\{/.test(enclosing) && !/\.catch\(/.test(enclosing)) {
|
||||
const line = source.slice(0, match.index).split("\n").length;
|
||||
offenders.push(`${file}:${line}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The body of the nearest `function` declaration containing `index`, found by
|
||||
* brace matching forward from its opening brace. Falls back to the whole file
|
||||
* when the call is not inside a function declaration at all.
|
||||
*/
|
||||
function enclosingFunction(source: string, index: number): string {
|
||||
const start = source.lastIndexOf("function ", index);
|
||||
if (start === -1) return source;
|
||||
|
||||
let depth = 0;
|
||||
let i = source.indexOf("{", start);
|
||||
if (i === -1 || i > index) return source;
|
||||
const open = i;
|
||||
for (; i < source.length; i++) {
|
||||
if (source[i] === "{") depth++;
|
||||
else if (source[i] === "}") {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
return index < i ? source.slice(open, i + 1) : source;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { test, expect, describe } from "bun:test";
|
||||
import { requestUpdate, type UpdateHandle } from "./swUpdate.ts";
|
||||
|
||||
function fakeHandle() {
|
||||
const events: string[] = [];
|
||||
let controllerListener: (() => void) | null = null;
|
||||
let timer: { fn: () => void; ms: number } | null = null;
|
||||
let unsubscribed = false;
|
||||
|
||||
const handle: UpdateHandle = {
|
||||
postSkipWaiting() {
|
||||
events.push("postSkipWaiting");
|
||||
},
|
||||
onControllerChange(listener) {
|
||||
events.push("listen");
|
||||
controllerListener = listener;
|
||||
return () => {
|
||||
unsubscribed = true;
|
||||
};
|
||||
},
|
||||
setTimer(fn, ms) {
|
||||
timer = { fn, ms };
|
||||
return () => {};
|
||||
},
|
||||
reload() {
|
||||
events.push("reload");
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
handle,
|
||||
events,
|
||||
get unsubscribed() {
|
||||
return unsubscribed;
|
||||
},
|
||||
fireControllerChange: () => controllerListener?.(),
|
||||
fireTimeout: () => timer?.fn(),
|
||||
get timerMs() {
|
||||
return timer?.ms;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("applying a waiting service worker", () => {
|
||||
/**
|
||||
* The bug: applyUpdate posted SKIP_WAITING and called reload() on the very
|
||||
* next line. Measured in Chrome, the controller had NOT changed at that
|
||||
* instant — it changed 2 ms later — so the reload raced activation. When the
|
||||
* reload won, the fresh page still saw registration.waiting and showed
|
||||
* "A new version is ready" again, and clicking Reload re-entered the same
|
||||
* race. The prompt never went away.
|
||||
*/
|
||||
test("does not reload until the new worker actually takes control", () => {
|
||||
const f = fakeHandle();
|
||||
requestUpdate(f.handle);
|
||||
expect(f.events).toEqual(["listen", "postSkipWaiting"]);
|
||||
expect(f.events).not.toContain("reload");
|
||||
});
|
||||
|
||||
test("listens before posting, or a fast activation is missed entirely", () => {
|
||||
const f = fakeHandle();
|
||||
requestUpdate(f.handle);
|
||||
expect(f.events.indexOf("listen")).toBeLessThan(f.events.indexOf("postSkipWaiting"));
|
||||
});
|
||||
|
||||
test("reloads once the controller changes", () => {
|
||||
const f = fakeHandle();
|
||||
requestUpdate(f.handle);
|
||||
f.fireControllerChange();
|
||||
expect(f.events).toEqual(["listen", "postSkipWaiting", "reload"]);
|
||||
});
|
||||
|
||||
/**
|
||||
* A worker that ignores SKIP_WAITING — the deliberately inert one the dev
|
||||
* server serves, or a genuinely broken build — must not leave the Reload
|
||||
* button dead. Reload anyway; the user asked for it.
|
||||
*/
|
||||
test("reloads anyway if the worker never takes control", () => {
|
||||
const f = fakeHandle();
|
||||
requestUpdate(f.handle, 1500);
|
||||
expect(f.timerMs).toBe(1500);
|
||||
f.fireTimeout();
|
||||
expect(f.events).toEqual(["listen", "postSkipWaiting", "reload"]);
|
||||
});
|
||||
|
||||
test("reloads exactly once when both the change and the timeout fire", () => {
|
||||
const f = fakeHandle();
|
||||
requestUpdate(f.handle);
|
||||
f.fireControllerChange();
|
||||
f.fireTimeout();
|
||||
expect(f.events.filter((e) => e === "reload")).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("stops listening once it has reloaded", () => {
|
||||
const f = fakeHandle();
|
||||
requestUpdate(f.handle);
|
||||
f.fireControllerChange();
|
||||
expect(f.unsubscribed).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Sequencing for "apply the waiting service worker, then reload".
|
||||
*
|
||||
* Pure, with every browser touchpoint injected, because the bug it fixes is
|
||||
* entirely about ORDER and order is what a test can pin. The old code was:
|
||||
*
|
||||
* waiting.postMessage({ type: "SKIP_WAITING" });
|
||||
* window.location.reload();
|
||||
*
|
||||
* Measured in Chrome: at the moment of that reload the new worker was still
|
||||
* `installed` and the controller had not changed — it changed 2 ms later. So
|
||||
* the reload raced activation. When the reload won, the fresh page load still
|
||||
* found `registration.waiting` and showed "A new version is ready" again, and
|
||||
* pressing Reload re-entered the same race. The prompt never went away.
|
||||
*
|
||||
* The window is wider than 2 ms in practice: the worker's `activate` handler
|
||||
* deletes every stale cache before calling `clients.claim()`.
|
||||
*/
|
||||
export interface UpdateHandle {
|
||||
/** Tells the waiting worker to activate now. */
|
||||
postSkipWaiting(): void;
|
||||
/** Subscribes to the page's controller changing. Returns an unsubscribe. */
|
||||
onControllerChange(listener: () => void): () => void;
|
||||
setTimer(fn: () => void, ms: number): () => void;
|
||||
reload(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* `timeoutMs` is a backstop, not a delay: a worker that ignores SKIP_WAITING —
|
||||
* the deliberately inert one the dev server serves, or a broken build — must
|
||||
* not leave the Reload button doing nothing at all. It reloads anyway, because
|
||||
* that is what the user asked for.
|
||||
*/
|
||||
export function requestUpdate(handle: UpdateHandle, timeoutMs = 3000): void {
|
||||
let reloaded = false;
|
||||
|
||||
const finish = () => {
|
||||
if (reloaded) return;
|
||||
reloaded = true;
|
||||
stopListening();
|
||||
cancelTimer();
|
||||
handle.reload();
|
||||
};
|
||||
|
||||
// Subscribe BEFORE posting. Activation can complete in a couple of
|
||||
// milliseconds, and a listener attached afterwards would miss it and fall
|
||||
// through to the timeout every time.
|
||||
const stopListening = handle.onControllerChange(finish);
|
||||
const cancelTimer = handle.setTimer(finish, timeoutMs);
|
||||
|
||||
handle.postSkipWaiting();
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { GlobalRegistrator } from "@happy-dom/global-registrator";
|
||||
// Bun can run several test files in one process and other files register
|
||||
// happy-dom too — guard so they can all run together.
|
||||
if (!GlobalRegistrator.isRegistered) {
|
||||
GlobalRegistrator.register();
|
||||
}
|
||||
|
||||
import { test, expect, describe, beforeEach } from "bun:test";
|
||||
import {
|
||||
THEME_KEY,
|
||||
applyResolvedTheme,
|
||||
isThemePreference,
|
||||
readThemePreference,
|
||||
resolveTheme,
|
||||
storeThemePreference,
|
||||
} from "./theme.ts";
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
document.documentElement.classList.remove("dark");
|
||||
});
|
||||
|
||||
describe("resolving a preference to an actual theme", () => {
|
||||
test("an explicit choice ignores the system setting entirely", () => {
|
||||
expect(resolveTheme("light", true)).toBe("light");
|
||||
expect(resolveTheme("dark", false)).toBe("dark");
|
||||
});
|
||||
|
||||
test("'system' follows the operating system", () => {
|
||||
expect(resolveTheme("system", true)).toBe("dark");
|
||||
expect(resolveTheme("system", false)).toBe("light");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reading the stored preference", () => {
|
||||
test("defaults to following the system when nothing is stored", () => {
|
||||
expect(readThemePreference()).toBe("system");
|
||||
});
|
||||
|
||||
test("round-trips each preference", () => {
|
||||
for (const preference of ["light", "dark", "system"] as const) {
|
||||
storeThemePreference(preference);
|
||||
expect(readThemePreference()).toBe(preference);
|
||||
}
|
||||
});
|
||||
|
||||
// A value can only be junk if something else wrote it, or the user edited
|
||||
// it. Following the system beats throwing during boot.
|
||||
test("junk in storage falls back to following the system", () => {
|
||||
localStorage.setItem(THEME_KEY, "solarized");
|
||||
expect(readThemePreference()).toBe("system");
|
||||
});
|
||||
|
||||
test("only the three real preferences are accepted", () => {
|
||||
expect(isThemePreference("light")).toBe(true);
|
||||
expect(isThemePreference("dark")).toBe(true);
|
||||
expect(isThemePreference("system")).toBe(true);
|
||||
expect(isThemePreference("Dark")).toBe(false);
|
||||
expect(isThemePreference(null)).toBe(false);
|
||||
expect(isThemePreference(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applying a theme", () => {
|
||||
test("dark adds the class shadcn's palette is keyed on, light removes it", () => {
|
||||
applyResolvedTheme("dark");
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(true);
|
||||
applyResolvedTheme("light");
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(false);
|
||||
});
|
||||
|
||||
test("applying the same theme twice is not a toggle", () => {
|
||||
applyResolvedTheme("dark");
|
||||
applyResolvedTheme("dark");
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* index.html carries a tiny inline script that applies the stored theme before
|
||||
* the stylesheet paints, so a dark-theme user never sees a white flash. It
|
||||
* cannot import this module, so it repeats the key and the logic — and this is
|
||||
* what stops the two drifting apart.
|
||||
*/
|
||||
test("the no-flash script in index.html agrees with this module", async () => {
|
||||
const html = await Bun.file("src/index.html").text();
|
||||
expect(html).toContain(THEME_KEY);
|
||||
expect(html).toContain("prefers-color-scheme: dark");
|
||||
expect(html).toContain("dark");
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Theme preference: light, dark, or follow the system.
|
||||
*
|
||||
* Stored in localStorage rather than in the dataset, deliberately. The dataset
|
||||
* is the owner's budget — it is exported, imported and versioned, and a
|
||||
* per-device display choice does not belong in it. Importing a backup taken on
|
||||
* a desktop should not flip a phone to dark.
|
||||
*
|
||||
* Every storage access is wrapped: localStorage THROWS on access in some
|
||||
* configurations (a browser set to block site data, some private modes), not
|
||||
* merely returning null, and a theme preference must never be the reason the
|
||||
* app fails to boot.
|
||||
*/
|
||||
|
||||
export const THEME_KEY = "budget2:theme";
|
||||
|
||||
/** What the user chose. */
|
||||
export type ThemePreference = "light" | "dark" | "system";
|
||||
|
||||
/** What that resolves to right now. Only these two reach the DOM. */
|
||||
export type ResolvedTheme = "light" | "dark";
|
||||
|
||||
const PREFERENCES: readonly string[] = ["light", "dark", "system"];
|
||||
|
||||
export function isThemePreference(value: unknown): value is ThemePreference {
|
||||
return typeof value === "string" && PREFERENCES.includes(value);
|
||||
}
|
||||
|
||||
export function readThemePreference(): ThemePreference {
|
||||
try {
|
||||
const stored = localStorage.getItem(THEME_KEY);
|
||||
return isThemePreference(stored) ? stored : "system";
|
||||
} catch {
|
||||
return "system";
|
||||
}
|
||||
}
|
||||
|
||||
export function storeThemePreference(preference: ThemePreference): void {
|
||||
try {
|
||||
localStorage.setItem(THEME_KEY, preference);
|
||||
} catch {
|
||||
// A preference that cannot be remembered is a small loss; a crash is not.
|
||||
}
|
||||
}
|
||||
|
||||
export function prefersDark(): boolean {
|
||||
try {
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveTheme(
|
||||
preference: ThemePreference,
|
||||
systemPrefersDark: boolean,
|
||||
): ResolvedTheme {
|
||||
if (preference === "system") return systemPrefersDark ? "dark" : "light";
|
||||
return preference;
|
||||
}
|
||||
|
||||
/**
|
||||
* shadcn's dark palette is keyed on a `.dark` class rather than a media query
|
||||
* (`@custom-variant dark (&:is(.dark *))` in styles/globals.css), so this
|
||||
* class is the single switch for both shadcn's variables and the app's own
|
||||
* `--budget-*` tokens.
|
||||
*/
|
||||
export function applyResolvedTheme(theme: ResolvedTheme): void {
|
||||
document.documentElement.classList.toggle("dark", theme === "dark");
|
||||
}
|
||||
Reference in new issue
Block a user