Commit Graph
7 Commits
Author SHA1 Message Date
nikolaandClaude Opus 5.5 3d72a54be1 Add a tags screen
Screen 8 lists every distinct tag -- the ones the index holds and the
ones rules.toml names -- with how many transactions carry it and the
rules that write it, each by file position with the transactions it
wins. It is where near-duplicate tags and tags several rules feed show
up, and a rule beaten by a more specific one reads (0) there as it does
on the rules screen. A tag no rule names any more says the next retag
clears it. The transfer label is not a tag and is never listed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:30:38 +02:00
nikolaandClaude Opus 5.5 a5f7541980 Name NLB uploads, delete statements, and stop importing on upload
Four changes to statement handling in the web app, made together and
touching the same upload and statements-list code.

Name NLB uploads by statement date. parser.Namer is an optional
interface, like Warner, through which a parser names its statements;
nlb reads the "Datum izpiska" from the izpisek header and names it
izpisek_YYYY_MM_DD, lowercase, extension included -- ported from the
rename_izpiski.py it replaces. Uploads are staged as dotfiles, invisible
to import, so the parser can read them; two downloads of one statement
then meet under one name and the second is recognised as already there,
while a different statement of the same date is numbered _2 as the
script did. Only uploads are named: source_files records statements by
path, so renaming a file already in a folder would orphan its rows.

Delete a statement from the statements list. The file is removed from
disk for good -- the page says so before it asks -- and
store.ForgetSourceFile drops its transactions and their transfer rows.
A row two overlapping statements share is stored once, under the file
imported first, so it goes too; the account's other statements forget
their checksums and show as changed until the next Import re-reads them
and restores it. A file already gone from disk can be forgotten.

Upload and delete no longer import. Importing stays the user's call,
made with the Import button, so a batch can be put together and looked
over first. Delete still re-pairs transfers, which reads no statement.

Show rows and new rows per statement. The list read "0" for a file
whose rows an earlier, overlapping statement already held, which looked
like a file that failed to parse. source_files now records how many
transactions each statement holds, and the list reads "3 rows · 0 new".

This adds a column the code reads, so an index built by an earlier
version fails with "no such column: s.rows": delete index.db and import
again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 21:48:29 +02:00
nikolaandClaude Opus 5.5 9ad6c05f9a List each account's statement files, and open them
The Accounts screen gets a Statements list: every file in every account
folder beside what the index made of it -- imported, changed since,
not imported yet (or failed), or gone from disk while its rows remain --
with its size, modified and import times, and how many transactions it
brought in. Clicking a name opens the file.

The list is read from the folders, not the index, through
importer.StatementFiles, so a file shows exactly when import would read
it; store.SourceFiles and importer.Checksum then say how far each has
got. A file the index remembers but the disk lost is listed as missing
rather than vanishing, since its rows would not survive a rebuild.

A file is served only by finding it in that list, never by joining the
requested name onto a path. Statements come from outside and are served
from the app's origin, so none is rendered as a page: text is text/plain
under CSP sandbox, anything not text or PDF is a sandboxed download, and
PDFs -- whose viewers refuse a sandbox -- open in the browser's own
isolated viewer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:00:03 +02:00
nikolaandClaude Opus 5.5 7f5b4846ef Show which legs a transfer cannot pair, and what would pair them
The transfers screen counted unpaired legs per definition but never said
which transactions they were; finding them meant retyping the definition
into the builder. A ⚠ row now opens onto its legs, newest first, each
naming the missing side and what that side would have had to be:

  revolut → ?  arriving leg  +250.00 in other matching *FROM REVOLUT*,
                             dated 2026-02-01 to 2026-02-11

That description comes from transfers.Engine.Want, next to
bestCounterpart, so it is stated in the terms the pairing actually uses:
the other half of the definition, the window, and the amount -- exact,
widened only by the definition's own tolerance, and "any amount" across
currencies, where amounts are not compared. When the other account has
nothing imported at all, the line says so, since that is the usual cause.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 19:43:13 +02:00
nikolaandClaude Opus 5.5 7ec9976b80 Upload statements from the web app
The Accounts screen gets an Add statements panel: pick an account, drop
files on it or choose them, and they are saved into that account's folder
and imported. The folder stays the source of truth -- an upload only puts
a file where `money import` looks, then runs the same import as the
Import button, so deleting index.db and re-importing still loses nothing.

Files travel base64 inside JSON rather than as multipart. There is no
auth, and the JSON-only rule is what keeps another site's form from
posting here; multipart is exactly what such a form can send.

An upload never replaces a statement: identical contents are a no-op and
different ones are refused with 409. Names import would not read back --
not a plain file name, dotfiles, account.toml, outside the account's
include patterns -- are refused, and a batch is checked whole before any
of it is written. Files are written through a dotfile and renamed, so a
concurrent import never reads half of one.

The overview now lists the account folders on disk, read fresh so one
created after startup is a valid target; it replaces the configured
count the empty accounts screen used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 18:51:56 +02:00
nikolaandClaude Opus 5.5 ec8a844441 Remove the TUI; the web app is the frontend
money serve covers every screen the TUI had, so keeping both meant every
behaviour change landing twice. internal/tui goes, and with it bubbletea,
bubbles and lipgloss. `money` with no command now runs serve, the way it
used to open the TUI, and `money tui` is an unknown command.

Three invariants in CLAUDE.md were covered only by TUI tests. Two already
had web counterparts; TestPairedLegsAreNotUntagged is ported to the API:
a paired leg is neither listed as untagged nor offered to the rule
builder, while an unpaired one still is.

README's screen sections now describe the browser, which kept the
behaviour and changed only the controls. CLAUDE.md names the web
equivalents of the TUI functions its invariants pointed at, drops the tab
completion rule that only bubbles' textinput needed, and says how to test
the web app instead of how to drive a terminal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 18:51:54 +02:00
nikolaandClaude Opus 5.5 6faf99719a Serve the TUI's screens as a web app
money serve puts the seven screens in a browser: accounts, transactions, the
report with its period axis and sort, the rule builder with its live preview
and edit-in-place, the rules list, the transfer builder with its tolerance
preview, and the transfers list. Import and retag are buttons in the header.
It is one binary: the page is plain HTML, CSS and JavaScript embedded with
go:embed, with no framework and no build step.

It is a second frontend, not a second implementation. Amounts are formatted on
the server, the rule preview is matched by glob.Match there, and the transfer
preview runs transfers.Analyze, so the browser only lays out answers and
cannot drift from the TUI on what a rule catches or what a pair costs.

The server outlives hand edits to rules.toml in a way the TUI does not, so
retag and import re-read it first, as a fresh `money retag` or `money import`
would, and the overview says when the file on disk no longer matches what the
index was derived from. Edits and deletes still go by position, but carry the
rule they showed and are refused unless rules.toml still holds exactly that
there; a position from a stale tab could otherwise name a different rule. An
edit takes the type pattern from the rule on disk, never from the request.

There is no authentication, by request. It listens on loopback unless --addr
says otherwise, and writes must be sent as JSON so a cross-site form cannot
post to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 09:48:56 +02:00