Commit Graph
5 Commits
Author SHA1 Message Date
Lucas Winther 804f32c5fb feat: add session undo and redo
Continuous Integration / docker-build-and-push (push) Successful in 34s
Continuous Integration / lint-and-test (push) Successful in 14s
2026-09-30 01:33:29 +02:00
Lucas WintherandClaude Opus 5 ac3d18a483 feat(settings): reset everything back to a fresh install
Asked for as a reset that includes the currencies and the seed
categories. Between an empty dataset and first-run state the owner chose
first-run: the three starter posts, the DKK/USD/EUR table, the baked
rates, this month at zero income, no purchases.

store.reset() calls the same createSeedDataset(currentMonth, seedRates)
that load() uses on a first run, through the same write queue. Seeding
stays in the store rather than moving into the UI so "reset" and "a
brand-new browser" cannot drift apart, and a reset issued while a
mutation is still in flight cannot be overtaken by it.

The confirm panel reuses the import flow's shape and, more importantly,
its order of operations: the JSON backup downloads BEFORE the
destructive write, and a backup that fails aborts the reset rather than
destroying the only copy of the user's data. The write is guarded, which
src/ui/storeWriteErrors.test.ts requires of every direct store call in
the UI.

Also gives both confirm panels and the error line the top margin they
never had — they butted straight against the button row.

Verified by clicking through in real Chrome and reading IndexedDB back:
3 seed posts, DKK/USD/EUR, both baked manual rates, 0 purchases, one
month at zero income. Whether headless Chrome wrote the backup file is
not observable here, so the backup is issued rather than confirmed
openable — a gap the import flow has always had too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 21:37:33 +02:00
Lucas WintherandClaude Opus 5 b4e1933761 feat: bake FX rates in at build time so a fresh install can convert
TODO section 2, designed and approved, now built. A brand-new dataset arrives
with DKK/USD/EUR rates instead of erroring on the first EUR purchase.

build.ts fetches at build time and embeds the result via `define`. A network
failure does NOT fail the build — it bakes committed constants instead, and an
incomplete response is treated as a failure too, since one unconvertible
currency is the problem this solves. Fetched rates carry source "api" and the
BUILD date, which is why parseFxResponse now takes an explicit updatedAt; the
constants carry source "manual" and their own date, so Settings never claims a
hardcoded number came from a rate service. SKIP_FX_FETCH=1 and FX_API_URL are
there for offline, CI and reproducible builds.

They seed a NEW dataset and nothing else. They are NOT a fallback for a missing
rate: clearing a rate removes its row so MissingRateError surfaces, rather than
silently converting money at a number the user never chose. A test asserts a
cleared rate still throws, and loading an existing dataset never touches its
rates.

Domain purity forced the shape. createSeedDataset takes the rates as an
argument (and copies the rows, so shared constants cannot be edited out from
under the caller); the env read lives in the new src/store/bakedRates.ts behind
the try/catch accessor, because an unset BUN_PUBLIC_* leaves a bare process.env
in the browser bundle; and createSnapshotStore takes seed rates as a parameter
so it stays free of the ambient environment, with src/store/index.ts wiring it.

Verified all three build paths: live fetch bakes api-sourced rates with today's
date, an unreachable endpoint and SKIP_FX_FETCH both bake manual constants and
still build, and the payload is present in the bundle in each case. Verified at
runtime that a fresh install converts EUR 100 -> 747.50 DKK and USD 100 ->
644.95 DKK with the variable both set and unset.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 14:37:39 +02:00
Lucas WintherandClaude Opus 5 ed1667f8a0 fix: serialize snapshot mutations, catch export/plan throws in event handlers
Fixes the three findings from the final whole-branch review:

- SnapshotStore.mutate/replace now serialize behind a promise queue, so
  concurrent mutate() calls (e.g. two independent onChange handlers firing
  before either write resolves) each clone from the previous commit instead
  of racing on a stale snapshot and silently losing an edit. Preserves
  write-before-move-snapshot, caller-visible rejections, structuredClone
  identity, and non-wedging on a failed write. Adds tests for concurrent
  mutations and a failing write mid-queue.
- DataSection's exportJson/exportOds are wrapped in try/catch and report
  failures through the existing import error state/rendering, since a throw
  (e.g. MissingRateError from buildOds) inside a plain onClick is invisible
  to the error boundary. The pre-import JSON backup now explicitly aborts
  the destructive replace if the backup export fails, rather than relying on
  an uncaught throw to prevent it.
- PlanEditor no longer calls monthOf()/withPlan() with an empty or partial
  date from a native <input type="date">, and requires an integer month
  count (with step={1}) before calling withPlan/equalSlices, preventing
  both domain throws from firing inside plain event handlers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 01:10:12 +02:00
Lucas WintherandClaude Opus 5 0625a5ffa5 feat(store): add in-memory snapshot with write-through
Persistence is injected, so the store is testable with no browser. Writes
land before the snapshot moves, so the UI never shows unsaved data.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 19:58:47 +02:00