Commit Graph
42 Commits
Author SHA1 Message Date
Lucas Winther 68712e72a3 feat(domain): allocation rules become a dated series
Post.standingRule becomes Post.rules: RuleVersion[], resolved by ruleAt() as
the last version effective at or before a month. Per-month overrides still win
outright; a post with no applicable version allocates zero rather than
applying its earliest rule backwards.

allocationFor keeps its signature, so the fold, the views and the ODS export
are untouched. Settings gets a read-only rule summary for now; the history
editor follows.
2026-09-02 02:15:34 +02:00
Lucas WintherandClaude Opus 5 c74a66aea9 docs: implementation plan for time-versioned allocation rules
Six tasks. Task 1 is deliberately large because the model change is atomic
under TypeScript: Post.standingRule -> Post.rules will not compile as a partial
change, so everything referencing it moves together, with the settings UI
getting only a read-only summary until Task 5.

Self-review closed two spec requirements that had no test: a version dated
before the fold start is legal but inert, and an archived post still resolves
its rules because it still folds.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-02 02:08:34 +02:00
Lucas WintherandClaude Opus 5 acf973f322 docs: spec time-versioned allocation rules
A post's allocation rule becomes a dated series: a version takes effect in its
month and continues until the next replaces it. Per-month overrides survive
alongside as one-month exceptions, because collapsing the two loses the
distinction between a permanent change and a temporary one.

A post with no version yet allocates zero rather than applying its earliest
rule backwards, which would invent budget for months never budgeted and let
the rollover fold carry that fiction forward.

Requires the project's first real migration (schemaVersion 1 -> 2). It is a
pure shape change and behaviour-preserving by construction, and the spec makes
that a tested property rather than an assumption: every month's allocation must
be identical before and after.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-02 02:02:53 +02:00
Lucas WintherandClaude Opus 5 ae34f9519d feat(ui): keyboard-first bulk rows, optional day, notes per purchase
Implements docs/superpowers/specs/2026-09-02-fast-entry-refinements-design.md.
Three changes shipped together because they share one tab order.

Bulk rows are description -> amount -> note, and nothing else is a tab stop.
The list always ends in exactly one blank row, appended as soon as the last
row is filled, so Tab flows into a new line with no special case and new rows
appear on touch too. The Add-line button is gone; the per-row remove button is
now a hover/focus x with tabIndex={-1}, and Backspace in an empty description
removes the row and lands on the previous row's note.

Purchases no longer demand a day. `date` accepts "2026-09" as well as
"2026-09-14"; entry defaults to the month, with an opt-in "set exact date"
link. No migration: monthOf() already parsed both shapes, verified before
choosing this over splitting the field into month + optional day, which would
have meant a schema migration in the browser against the user's only copy of
their data for an identical outcome.

Notes are an optional field on Purchase, in both entry forms and shown beside
the description in the month lists. Empty notes are omitted rather than stored
as "", so absent stays absent.

Also closes a pre-existing gap the spec surfaced: the JSON importer never
validated purchase.date, so a malformed date passed import and threw later
inside the balance fold. It is now rejected at the boundary, with out-of-range
months and days covered.

A row holding only a note is deliberately NOT blank: it asks for the missing
description and amount rather than being silently discarded.

Keyboard behaviour - tab traversal, Backspace-to-remove, focus after append -
cannot be verified in this environment and needs confirming in a browser.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-02 01:57:58 +02:00
Lucas WintherandClaude Opus 5 6924a593fb docs: spec fast-entry refinements (tab flow, optional day, notes)
Three entry-form changes specified together because they share the same tab
order: keyboard-driven bulk rows, purchases that no longer demand a day, and
an optional note per purchase.

Records why the date change widens IsoDate rather than splitting it into
month + optional day: the split is the better model but changes a type every
layer depends on and needs a migration run in the browser against the user's
only copy of their data, for an identical user-visible outcome. Verified that
monthOf() already parses both date shapes and still rejects out-of-range
months.

Time-versioned allocation rules are explicitly out of scope and get their own
cycle.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-02 01:53:43 +02:00
Lucas WintherandClaude Opus 5 da87bff9c3 feat(ui): add fast entry for many purchases at once
A grocery trip is twenty lines, and opening the full dialog twenty times
is the thing this exists to avoid.

PurchaseDialog gains a One / Many toggle, shown only when adding — editing
an existing purchase edits exactly one, so the toggle is hidden there. In
Many mode the date, post and currency are set once at the top and each line
is just a description and an amount; Enter on an amount starts the next
line, so a receipt can be typed without leaving the keyboard. Blank trailing
rows are ignored rather than flagged.

The whole batch commits in a single mutate: one write, one queue entry, and
no way to half-commit a shopping trip.

Lines are deliberately simple. A line needing a split or a payment plan is
saved plain and then edited from the month view — building per-line split
and plan editors would have been most of the work for a case the grocery
run never hits, and would have slowed the common path.

Line logic lives in a pure module (src/ui/bulkEntry.ts) following the
purchaseForm.ts pattern, so it is testable with no DOM: 20 tests covering
blank-row handling, per-row validation messages, the running total, and
that N lines become N purchases landing wholly in the shared post. Each
line is built through the same toPurchase the single form uses, so there
is one definition of what a purchase is.

Validation mirrors validatePurchase in what it deliberately does NOT
reject: a negative amount is a refund, not an error.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-02 01:44:55 +02:00
Lucas WintherandClaude Opus 5 417adb0d85 fix(ui): capture event values before deferring to mutate()
The "Income this month" field could not be changed from 0. Typing zeros
appeared to work; typing any non-zero snapped the field back to a single 0.

mutate() defers its callback behind the store's serialization queue and an
IndexedDB write. React re-renders and resets a controlled input's DOM value
back to the last committed value well before that callback runs, so a handler
reading event.target.value INSIDE the callback read the reset value and
committed 0 — forever. Committing 0 matches what is displayed, which is why
typing zeros looked fine.

Six handlers had this shape (income, post name, post currency, standing-rule
kind, base currency, rate service URL). Each now reads the DOM value
synchronously and passes it in. Sites that already captured into a const first
— the FX rate and the standing-rule amount — were never affected, which is why
those fields worked.

Purchase, split and plan editors were never affected either: they write to
local component state synchronously, never through the deferred queue.

Adds a source-level regression guard, because the trigger is real-browser
render timing that happy-dom does not reproduce.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-02 01:36:46 +02:00
Lucas WintherandClaude Opus 5 d1d6735596 fix(ui): survive an unset BUN_PUBLIC_BASE_PATH in the browser
Bun inlines a literal `process.env.FOO` reference only when FOO is set. With
it unset — a bare `bun --hot src/index.ts`, which is what the README tells you
to run — the reference survived into the client bundle and the app died on
load with `ReferenceError: process is not defined`, before rendering anything.
Production builds were unaffected, because build.ts passes an explicit define.

Reading the value now goes through readBasePathEnv(), which catches. Catching
rather than a `typeof process` guard is deliberate: after inlining the literal
becomes a string, but a typeof test would still run in the browser and discard
that inlined value, silently breaking every subpath deploy. Verified all four
paths: dev unset (reference survives, caught, base "/"), dev set (inlined),
and root + subpath production builds (inlined by define).

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-02 01:19:51 +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) <[email protected]>
2026-09-02 01:10:12 +02:00
Lucas WintherandClaude Opus 5 da0d1ac35c fix(ui): catch render throws with an error boundary around routed content
A purchase in a currency with no configured exchange rate makes
src/domain/fx.ts throw MissingRateError (correct behavior — never silently
convert at a wrong rate). Nothing caught it: 5 of 6 routes call
monthView/yearView/summaryView and threw during render, and with no error
boundary anywhere in src/, the throw unmounted the entire React tree
including the nav bar. Since the catch-all route redirects to the current
month on first load, one un-rated foreign-currency purchase white-screened
the app immediately on landing, with the only way out (Settings, where the
rate is added) unreachable because the nav was gone too.

Adds RouteErrorBoundary (a class component, as required for
getDerivedStateFromError/componentDidCatch) that renders the thrown
message plus a link to Settings and a "Try again" reset, and
RouteErrorBoundaryReset, a wrapper that keys the boundary on the current
route so it clears on navigation. Wraps only <Routes> in App.tsx, not
<nav>, so navigation stays usable when a route throws.

Also guards the happy-dom per-file GlobalRegistrator.register() call in
registerSw.test.ts and the new ErrorBoundary.test.tsx with
isRegistered, since Bun can run both files in one process.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-02 00:57:05 +02:00
Lucas Winther 6eb77c8733 feat(deploy): add Docker image, static server, and CI workflows
One build artifact serves both a self-hosted subpath deploy and GitHub
Pages. CI runs the tests, typechecks, builds, and smoke-tests the image.

server.ts's traversal guard uses a separator-aware check
(resolved === root || resolved.startsWith(root + path.sep)) instead of
a bare startsWith(root), which would let a request resolving to
"/app/dist-evil/x" pass a check against root "/app/dist".
2026-09-01 21:33:45 +02:00
Lucas WintherandClaude Opus 5 4478c46a43 fix(pwa): make manifest/icon hrefs absolute for multi-segment SPA routes
Relative hrefs in the built index.html ("manifest.webmanifest", "icon.svg")
resolve against the *document* URL, not the site root. Since this is an
SPA where every route serves the same shell, a route with more path
segments than the base path (e.g. /budget2.0/month/2026-09, which is
where the app's own catch-all route redirects on first load) resolved
these to a wrong, 404ing path — breaking the manifest and favicon, and
with them installability, on the app's default landing URL.

build.ts now rewrites both hrefs to be absolute (prefixed with basePath)
as part of the existing dist/index.html -> dist/404.html step, keeping
the two files byte-identical. src/index.html itself keeps the plain
relative hrefs unchanged, since the substitution only happens in the
built dist/ output.

Also fixes a pre-existing, previously-uncaught bug surfaced while
verifying the dev server: bun --hot src/index.ts 500'd on every request
because Bun's dev-server HTML bundler (unlike Bun.build) has no
equivalent of the `external` build option and always tries to resolve
<link> hrefs as module imports — manifest.webmanifest never existed as a
real file under src/, only as build.ts's generated dist/ output. Added
src/manifest.webmanifest as a small static placeholder so the dev server
can resolve it; the production build still ignores this file entirely
(external: ["*.webmanifest"] in build.ts) and writes its own generated
manifest, unaffected by this addition.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-01 21:26:45 +02:00
Lucas WintherandClaude Opus 5 c069ed3a94 feat(pwa): add service worker, manifest, and subpath-aware build
The precache list is generated from Bun.build's outputs, so hashed asset
names cannot drift. A waiting worker prompts to reload rather than
pinning the user to a stale build.

Two fixes beyond the brief: naming.entry changed from "[dir]/[name].[ext]"
to "[name].[ext]" because [dir] resolved to "src", putting index.html at
dist/src/index.html instead of the dist root; and external:
["*.webmanifest", "*.svg"] was added because Bun's HTML bundler otherwise
tries to resolve <link> hrefs as module imports, which fails outright for
the not-yet-generated manifest.webmanifest and would hash icon.svg,
breaking the fixed paths the manifest and service worker depend on.

happy-dom is registered per-file in src/ui/registerSw.test.ts only, not
as a global test preload, so src/domain/smoke.test.ts still guards the
domain layer's DOM-free invariant.

PNG icons (192px/512px) are not included — binary image generation isn't
available here; a human needs to add them for full Chrome installability.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-01 21:22:58 +02:00
Lucas WintherandClaude Opus 5 31f030fdea feat(ui): add settings for currency, rates, posts, and data
Rate fetching is optional and fails soft: any network or parse error
leaves cached rates untouched and shows a message, never throwing into
render. A cleared or non-positive FX rate input now calls the new
removeFxRate action instead of storing 0, since toBase() silently
converts a stored 0 to a 0-value conversion while an absent row
correctly throws MissingRateError. Posts are archived, never deleted.
Import downloads a backup of the current data before replacing it and
requires confirmation showing before/after counts.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-01 21:14:35 +02:00
Lucas Winther 408a473dcd feat(ui): add summary view with post and month grouping 2026-09-01 21:07:04 +02:00
Lucas WintherandClaude Opus 5 59e8f4549d fix(ui): stop showing cancelled finance-plan slices as committed
PostMonthRoute's "Already committed" list filtered future schedule slices
only by month, never consulting cancelledFromMonth, so a cancelled plan's
remaining slices kept appearing as future obligations the fold would never
actually charge. Reuse sliceAmountForMonth's existing cancellation rule
instead of re-deriving it inline, to avoid a second copy of the >= boundary
drifting from the domain's.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-01 21:01:53 +02:00
Lucas WintherandClaude Opus 5 6c0fc67f1c feat(ui): add year matrix and per-post month and year views
Future finance-plan slices are surfaced as already-committed so a plan is
visible before it lands.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-01 20:59:59 +02:00
Lucas Winther 5b879809d2 feat(ui): add finance plan editor
Plans default to an equal split, each slice is individually editable, and
plans compose with splits. Cancelling keeps past slices.

planBalance delegates to domain slicesBalance rather than recomputing the
same total-minus-slices arithmetic, per the project's one-implementation
rule for shared math.
2026-09-01 20:53:05 +02:00
Lucas WintherandClaude Opus 5 5a11941d39 feat(ui): add purchase dialog with split editor
Validation lives in purchaseForm.ts as pure functions. Unbalanced splits
and over-budget purchases are deliberately not errors.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-01 20:48:51 +02:00
Lucas WintherandClaude Opus 5 b278dcaf12 fix(ui): namespace carry-meter accent token to avoid shadcn contrast regression
A bare --accent in src/index.css silently overrode shadcn's own
--accent/--accent-foreground pairing (near-white background paired with
near-black text) with the design contract's dark teal, without touching
the paired foreground. That dropped the computed contrast ratio on every
outline/ghost button, select item, badge link and dialog close button
that uses bg-accent/text-accent-foreground to 3.23:1 -- below WCAG AA's
4.5:1 minimum for text.

Renamed the token to --budget-accent everywhere it's declared and
consumed (src/index.css, src/ui/components/PostTable.tsx's carry meter),
leaving shadcn's own --accent/--accent-foreground pair untouched. The
other five contract tokens (--paper, --ink, --rule, --surplus,
--overspend) don't collide with any shadcn token name in
styles/globals.css, so they are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-01 20:39:52 +02:00
Lucas WintherandClaude Opus 5 dd612565cb feat(ui): add month view with income entry and post table
Negative remaining balances are shown in a warning treatment; nothing
blocks going over budget.

Applies the Tasks 18-23 design contract: --paper/--ink/--rule/--accent/
--surplus/--overspend tokens and the three type-role font variables in
src/index.css, --font-money on every money figure, and the carry meter
(a 3px hard-stopped gradient beneath each PostTable row) encoding
carried-in surplus, allocation track, spend fill and overrun.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-01 20:35:35 +02:00
Lucas WintherandClaude Opus 5 ccb97818d7 feat(store): add plan slicing and dataset actions
Every mutation is a pure function over a draft dataset, so the write
surface is unit-tested without React. Money amounts (income, purchase
totals, split values, schedule slices, fixed/percent rule amounts) are
rounded to their currency's minor unit before they land in the draft,
per CLAUDE.md's round-before-persisting rule -- actions.ts is the
single boundary every UI mutation passes through. FxRate.baseUnitsPerOne
is deliberately exempt: it is stored at six decimal places, not money.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-01 20:25:14 +02:00
Lucas Winther 7edcc7f31e feat(ui): add app shell, router, and base path handling
Base path comes from BUN_PUBLIC_BASE_PATH so the same bundle serves both
a root Docker deploy and a GitHub Pages subpath.

Deviation from the task-16 brief: does not install @happy-dom/global-registrator,
does not create test/setup.ts, and does not add a [test] preload to bunfig.toml.
A global DOM preload would make src/domain/smoke.test.ts's "importable without a
DOM" assertion vacuously true, silently retiring the check that guards the
domain-layer-stays-pure invariant. Nothing in this task needs a DOM: basePath.test.ts
is pure string logic, and the shell render test was intentionally omitted by the
brief itself. A later task will register happy-dom per-file where a DOM is
genuinely required.
2026-09-01 20:17:57 +02:00
Lucas Winther 69bf4393fb feat(export): add ODS workbook export
Generated in-browser with fflate, so export works offline. mimetype is
stored first and uncompressed as the ODS spec requires.

Also adds datasetMonthSpan to the domain views layer, which determines
the month range an export should cover: from the fold start through
the latest month touched by any month record or purchase slice,
including future finance-plan slices.

Widened the mimetype-header test's byte slice from 64 to 96 bytes: the
zip local-file header for "mimetype" is 38 bytes and ODS_MIMETYPE is
46 characters, so 64 bytes could never contain the full string
regardless of implementation.
2026-09-01 20:11:08 +02:00
Lucas WintherandClaude Opus 5 c627051e66 feat(export): build ODS XML documents
Numeric cells carry office:value so the export can be summed and pivoted
in a spreadsheet rather than arriving as text.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-01 20:05:40 +02:00
Lucas WintherandClaude Opus 5 932b88b189 feat(export): add JSON export and validated import
Import validates far enough that the fold cannot throw on imported data:
exactly one remainder-absorbing split per purchase, at least one split,
every split.postId resolving to a real post, every currency supported,
and every MonthId well-formed and in range (01-12). Over-budget and
percentages not summing to 100 remain permitted.

Tightens MONTH_ID to /^\d{4}-(0[1-9]|1[0-2])$/ (shape-only regex would
let "2026-13" through import and silently misfile inside the balance
fold, mirroring the defect already fixed in src/domain/months.ts).

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-01 20:02:02 +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) <[email protected]>
2026-09-01 19:58:47 +02:00
Lucas WintherandClaude Opus 5 15ae45a804 feat(store): add IndexedDB persistence and schema migrations
The dataset is one atomic record, so a write can never leave posts and
purchases inconsistent.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-01 19:55:08 +02:00
Lucas WintherandClaude Opus 5 438d53d016 feat(domain): add seed dataset and domain barrel
Add SCHEMA_VERSION and createSeedDataset to initialize first-run state
with three budget envelopes (Video Games, Food, Events and Social),
newId() for generating unique identifiers, and complete the domain
export barrel with all pure domain modules.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-01 19:51:57 +02:00
Lucas Winther 564a5f4079 feat(domain): add month, year, and summary view models
All three aggregate over foldBalances rather than recomputing rollover.
2026-09-01 19:48:48 +02:00
Lucas Winther 4077036ca7 feat(domain): add the envelope rollover fold
Unspent carries forward, overspend carries as debt. This is the single
implementation of the rollover math.
2026-09-01 19:45:31 +02:00
Lucas Winther 71d69c20b3 feat(domain): distribute purchase slices across posts
Slices are weighted by the whole-purchase split parts, so financed split
purchases need no special case and parts always sum to the slice total.
2026-09-01 19:40:32 +02:00
Lucas WintherandClaude Opus 5 0df1971a03 feat(domain): resolve allocation rules
Month overrides win over standing rules; percentages above 100 are allowed.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-01 19:37:09 +02:00
Lucas WintherandClaude Opus 5 9af581e69c fix(domain): add month range validation to parse and monthOf
Shape-valid but out-of-range months (e.g., month 13 or 00) previously
normalized silently through ordinal arithmetic, resulting in wrong months
downstream. Now throw on invalid month values (< 1 or > 12), with the
offending value in the error message.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-01 16:33:27 +02:00
Lucas WintherandClaude Opus 5 bab6fc5f3e feat(domain): add currency conversion
Missing rates throw MissingRateError rather than silently treating the
value as base currency.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-01 16:31:22 +02:00
Lucas WintherandClaude Opus 5 261c6fa9ad feat(domain): add MonthId arithmetic
Uses a month ordinal rather than Date, so month stepping is timezone-proof.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-01 16:28:18 +02:00
Lucas Winther b2fa0e1b58 feat(domain): add money arithmetic with exact distribution
Distribution computes the remainder part by subtraction so float splits
always sum exactly to the total. Omits money() and addAmounts() from the
brief since no downstream task consumes either and the brief's own tests
don't cover them.
2026-09-01 16:25:40 +02:00
Lucas Winther e1ac6d9fac feat(domain): add domain types 2026-09-01 16:22:40 +02:00
Lucas Winther 26752aa225 chore: scaffold Bun + React + shadcn project
bun init --react=shadcn, with the demo API routes removed since this app
has no backend. Adds project invariants to CLAUDE.md and a smoke test
proving the domain layer imports with no DOM.
2026-09-01 16:18:55 +02:00
Lucas WintherandClaude Opus 5 abd77c58ed docs: add budget app implementation plan
25 TDD tasks across five phases: foundation, pure domain core, IndexedDB
persistence, JSON/ODS export, UI, and shipping (PWA + Docker + CI).

Self-review caught five defects, fixed inline:
- distributeByWeight divided by zero on all-zero weights, which a
  zero-total purchase reaches and which would take the whole fold down
- summaryView's fold binding needed an explicit type or CI typecheck fails
- a duplicated module import in registerSw.ts
- a shell render test that would break once its routes read the store,
  since happy-dom has no IndexedDB
- an unreadable assertion in the ODS numeric-cell test

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-01 16:13:40 +02:00
Lucas WintherandClaude Opus 5 6833a58c91 docs: add budget app design spec
Design for a personal budgeting PWA replacing a spreadsheet workflow.

Key decisions captured:
- Static SPA, no backend in the data path, so it deploys to both GitHub
  Pages and a self-hosted Docker image from one build artifact
- Bun toolchain (bun init --react=shadcn); no Vite, no Electron
- Pure derivation core over an IndexedDB fact store; envelope rollover
  makes caching worthless, so every figure is folded from raw facts
- One Purchase entity covers simple, split, and financed purchases, which
  is what lets splits and finance plans compose without special-casing
- Money is a float, with mandatory rounding at boundaries and last-part-
  by-subtraction so distributed parts sum exactly to the whole

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-01 15:50:46 +02:00
Lucas Winther eba36fad9d Initial commit 2026-09-01 15:23:37 +02:00