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>
This commit is contained in:
2026-10-02 09:48:56 +02:00
co-authored by Claude Opus 5.5
parent 7d4c3eba17
commit 6faf99719a
8 changed files with 2793 additions and 0 deletions
+23
View File
@@ -21,6 +21,7 @@ internal/rules applies ordered rules, writing rule_tag
internal/transfers pairs the two legs of a movement, writing the transfers table
internal/report per-tag aggregation
internal/tui Bubble Tea models
internal/web `money serve`: JSON API + embedded single page (static/)
```
## Invariants
@@ -224,6 +225,28 @@ back, and `refreshRulePreview` keeps the ones the new glob stops catching on
screen marked `−` instead of dropping them silently, because giving one up is
the decision being made.
**The web app is a second frontend, not a second implementation.**
`internal/web` ports the TUI's screens over HTTP, and everything that decides
something stays in Go: amounts are formatted server-side (`money` is text plus
a sign, never a number the browser could add up), the rule builder's preview is
matched by `glob.Match` on the server rather than re-implemented in JS, and the
transfer preview runs `transfers.Analyze`. Keep it that way — a JS glob or a
JS sum is a second copy of a rule that would drift from the first.
`static/app.js` only lays out what the API returns.
The web server outlives edits to rules.toml in a way the TUI does not, so
`/api/retag` and `/api/import` re-read it first (import also re-reads the
account folders), and `/api/overview` reports `stale` when the file on disk no
longer equals what the engines hold. Every edit and delete sends the rule or
transfer it showed alongside the position, and is refused with 409 unless
rules.toml still holds exactly that there — a position from a stale page can
otherwise name a different rule. An edit takes `Type` from the rule *on disk*,
never from the request, which is the web side of `TestRuleEditKeepsTypePattern`.
There is no auth by design (`--addr` defaults to loopback). Non-GET requests
must be `application/json`, which is what keeps a cross-site form from posting
to it; do not relax that without putting something else in its place.
## Adding a bank parser
Implement `parser.Parser` and call `parser.Register` from an `init`. Nothing
+43
View File
@@ -60,6 +60,8 @@ compiles only the files listed, which breaks the moment the package has two.
```
money # open the TUI (default)
money serve # the same screens as a web app, on 127.0.0.1:8080
money serve --addr :8080 # listen on every interface (there is no login)
money import # extract new transactions from every statement
money import --force # re-parse statements even if unchanged
money retag # re-apply rules.toml: retag, and re-pair transfers
@@ -408,6 +410,47 @@ after a `y`. Definitions marked `⚠` are never pruned — they are doing someth
just not finishing it, and deleting one would hide the problem rather than fix
it. `r` re-pairs against what is currently in the index.
## Web app
`money serve` puts the TUI's seven screens in a browser — accounts,
transactions, report, rule builder, rules, transfer builder, transfers — with
the same behaviour, because it runs the same code: amounts are formatted, globs
matched and transfers paired on the server, and the page only shows the
answers. It is one binary with the page built in; nothing else to deploy.
```
money serve # http://127.0.0.1:8080
money --root /srv/money serve --addr 0.0.0.0:8080
```
**There is no authentication.** Anyone who can reach the port can read every
transaction, rewrite `rules.toml` and start an import, which is why it listens
on loopback unless `--addr` says otherwise. To use it from elsewhere, put it
behind a reverse proxy that does the logging in, or reach it over SSH / a VPN.
Requests that change anything must be sent as JSON, so another website open in
the same browser cannot post a form to it.
What differs from the TUI:
- **Retag and Import re-read `rules.toml`** (and Import the account folders)
before running, exactly as a fresh `money retag` / `money import` would — the
server outlives hand edits to both. Until then a banner says the file on disk
no longer matches what the index was derived from.
- **Edits and deletes check the file first.** They go by rule position, as in
the TUI, but the page also sends the rule it showed you; if `rules.toml` no
longer holds that rule there (another tab, a hand edit) the change is refused
and you are asked to reload, rather than editing whichever rule moved into
its place.
- Clicking a description in the rule builder's preview fills the glob with
`*THAT DESCRIPTION*`, as a starting point to narrow down.
- Shift-click **Import** for `import --force`.
The keys still work where they do not fight the browser: `1`–`7` switch
screens, `/` searches, `u` toggles untagged, `a` clears the account filter, `i`
imports and `r` retags. On the report `←`/`→` move the period and `s` cycles
the sort (or click a column heading); in the rule builder `ctrl+s` re-sorts the
preview. Inside a form every printable key belongs to the field, as in the TUI.
## rules.toml
**The most specific rule wins**, so `*NIKOLA*` claims what it names even with a
+49
View File
@@ -6,13 +6,19 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"io"
"net/http"
"os"
"os/signal"
"sort"
"strings"
"syscall"
"text/tabwriter"
"time"
"git.petrovv.com/nikola/money/internal/config"
"git.petrovv.com/nikola/money/internal/importer"
@@ -23,6 +29,7 @@ import (
"git.petrovv.com/nikola/money/internal/store"
"git.petrovv.com/nikola/money/internal/transfers"
"git.petrovv.com/nikola/money/internal/tui"
"git.petrovv.com/nikola/money/internal/web"
)
const usage = `money - statement-driven personal finance tracker
@@ -32,6 +39,7 @@ usage: money [--root DIR] <command> [flags]
commands:
tui browse transactions, build the rules that tag them and the
transfers that pair them across accounts (default)
serve the same screens as a web app (--addr, default 127.0.0.1:8080)
import extract transactions from every statement into the index
retag re-apply rules.toml: retag everything and re-pair transfers
ls list transactions
@@ -103,6 +111,8 @@ func run(args []string) error {
return nil
case "tui":
return cmdTUI(abs, args)
case "serve":
return cmdServe(abs, args)
case "help", "-h", "--help":
fmt.Print(usage)
return nil
@@ -474,3 +484,42 @@ func cmdTUI(root string, _ []string) error {
defer o.db.Close()
return tui.Run(root, o.db, o.accounts, o.engine, o.links)
}
// cmdServe serves the TUI's screens over HTTP until interrupted. There is no
// authentication, so it listens on loopback unless told otherwise: anyone who
// can reach the port can rewrite rules.toml and start an import.
func cmdServe(root string, args []string) error {
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
addr := fs.String("addr", "127.0.0.1:8080", "address to listen on")
if err := fs.Parse(args); err != nil {
return err
}
o, err := open(root)
if err != nil {
return err
}
defer o.db.Close()
srv := &http.Server{
Addr: *addr,
Handler: web.New(root, o.db, o.accounts, o.engine, o.links).Handler(),
ReadHeaderTimeout: 10 * time.Second,
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go func() {
<-ctx.Done()
// Let an import in flight finish writing the index before it closes.
shutdown, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
srv.Shutdown(shutdown)
}()
fmt.Printf("serving %s on http://%s\n", root, *addr)
if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
}
File diff suppressed because it is too large Load Diff
+388
View File
@@ -0,0 +1,388 @@
package web
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.petrovv.com/nikola/money/internal/config"
"git.petrovv.com/nikola/money/internal/model"
"git.petrovv.com/nikola/money/internal/rules"
"git.petrovv.com/nikola/money/internal/store"
"git.petrovv.com/nikola/money/internal/transfers"
)
type fixtureTxn struct {
account, date, desc string
amount int64
}
// newTestServer builds a server over a data root holding rules.toml and an
// index with the given transactions, tagged and paired as an import would
// leave them. "Today" is fixed so the report's default window is predictable.
func newTestServer(t *testing.T, rulesToml string, txns ...fixtureTxn) (*Server, http.Handler) {
t.Helper()
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, config.RulesFile), []byte(rulesToml), 0o644); err != nil {
t.Fatal(err)
}
db, err := store.Open(config.IndexPath(root))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
ids := map[string][2]int64{}
for _, slug := range []string{"checking", "savings"} {
id, err := db.UpsertAccount(model.Account{Slug: slug, Name: slug, Currency: "EUR", MinorDigits: 2})
if err != nil {
t.Fatal(err)
}
src, err := db.SourceFile(id, slug+"/st.csv", "sha", "2026-01-01T00:00:00Z")
if err != nil {
t.Fatal(err)
}
ids[slug] = [2]int64{id, src}
}
for i, x := range txns {
if _, err := db.InsertTransaction(model.Transaction{
AccountID: ids[x.account][0], SourceFileID: ids[x.account][1],
Fingerprint: x.desc + x.date + string(rune('a'+i)),
Date: x.date, Description: x.desc, AmountMinor: x.amount,
}); err != nil {
t.Fatal(err)
}
}
loaded, err := config.LoadRules(root)
if err != nil {
t.Fatal(err)
}
engine, links := rules.New(loaded), transfers.New(loaded)
if _, err := engine.Retag(db); err != nil {
t.Fatal(err)
}
if _, _, err := links.Link(db); err != nil {
t.Fatal(err)
}
s := New(root, db, nil, engine, links)
s.now = func() time.Time { return time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC) }
return s, s.Handler()
}
// call sends a request and decodes the JSON answer, failing unless the status
// is the one expected.
func call(t *testing.T, h http.Handler, method, path string, body any, want int, out any) {
t.Helper()
var r *http.Request
if body == nil {
r = httptest.NewRequest(method, path, nil)
} else {
b, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
r = httptest.NewRequest(method, path, bytes.NewReader(b))
r.Header.Set("Content-Type", "application/json")
}
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != want {
t.Fatalf("%s %s: status %d, want %d: %s", method, path, w.Code, want, w.Body.String())
}
if out != nil {
if err := json.Unmarshal(w.Body.Bytes(), out); err != nil {
t.Fatalf("%s %s: decode %q: %v", method, path, w.Body.String(), err)
}
}
}
func readRules(t *testing.T, s *Server) *config.Rules {
t.Helper()
r, err := config.LoadRules(s.root)
if err != nil {
t.Fatal(err)
}
return r
}
const lidlRule = `
[[rule]]
match = "*LIDL*"
tag = "groceries"
`
// The period narrows the report and nothing else: the report opens on last
// month while the transaction list still holds the whole index.
func TestReportPeriodLeavesTransactionsAlone(t *testing.T) {
_, h := newTestServer(t, lidlRule,
fixtureTxn{"checking", "2026-02-10", "LIDL SOFIA", -1000},
fixtureTxn{"checking", "2025-11-03", "LIDL VARNA", -500},
)
var rep reportJSON
call(t, h, "GET", "/api/report", nil, http.StatusOK, &rep)
if got := rep.Periods[rep.Period].Label; got != "last month" {
t.Fatalf("report opens on %q, want last month", got)
}
if len(rep.Rows) != 1 || rep.Rows[0].Out.Text != "10.00" {
t.Errorf("last month's report = %+v, want groceries 10.00 only", rep.Rows)
}
var txns struct{ Rows []txnRow }
call(t, h, "GET", "/api/transactions", nil, http.StatusOK, &txns)
if len(txns.Rows) != 2 {
t.Errorf("transactions = %d rows, want both", len(txns.Rows))
}
call(t, h, "GET", "/api/report?period=all+time", nil, http.StatusOK, &rep)
if len(rep.Rows) != 1 || rep.Rows[0].Out.Text != "15.00" {
t.Errorf("all-time report = %+v, want groceries 15.00", rep.Rows)
}
}
// The edit form has no type field, so the type must come from the rule on
// disk; a pattern the user was never shown is not one they chose to remove.
func TestEditKeepsTypePattern(t *testing.T) {
s, h := newTestServer(t, `
[[rule]]
match = "*LIDL*"
type = "CARD_PAYMENT"
tag = "groceries"
`)
expect := ruleJSON{Match: "*LIDL*", Type: "CARD_PAYMENT", Tag: "groceries"}
call(t, h, "PUT", "/api/rules/0", editRuleReq{
Rule: ruleForm{Match: "*LIDL SOFIA*", Tag: "food"}, Expect: expect,
}, http.StatusOK, nil)
got := readRules(t, s).Rule[0]
if got.Type != "CARD_PAYMENT" || got.Match != "*LIDL SOFIA*" || got.Tag != "food" {
t.Errorf("edited rule = %+v, want the type kept", got)
}
}
// Positions are what rules.toml is edited by, so a page that saw a different
// rule in that position must be refused rather than edit the wrong one.
func TestStalePositionIsRefused(t *testing.T) {
s, h := newTestServer(t, lidlRule)
before, _ := os.ReadFile(filepath.Join(s.root, config.RulesFile))
call(t, h, "POST", "/api/rules/delete", deleteRulesReq{
Positions: []int{0}, Expect: []ruleJSON{{Match: "*ZARA*", Tag: "clothes"}},
}, http.StatusConflict, nil)
call(t, h, "PUT", "/api/rules/0", editRuleReq{
Rule: ruleForm{Match: "*X*", Tag: "x"}, Expect: ruleJSON{Match: "*ZARA*", Tag: "clothes"},
}, http.StatusConflict, nil)
after, _ := os.ReadFile(filepath.Join(s.root, config.RulesFile))
if !bytes.Equal(before, after) {
t.Errorf("rules.toml changed on a refused request:\n%s", after)
}
}
// A body that is not JSON is refused before it reaches a handler: with no
// authentication, that is what stops a cross-site form posting to the server.
func TestFormPostsAreRefused(t *testing.T) {
s, h := newTestServer(t, "")
r := httptest.NewRequest("POST", "/api/rules", strings.NewReader("match=*&tag=x"))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusUnsupportedMediaType {
t.Fatalf("status %d, want 415", w.Code)
}
if n := len(readRules(t, s).Rule); n != 0 {
t.Errorf("%d rules written by a form post", n)
}
}
// Saving a rule writes rules.toml and retags, so the description it caught is
// no longer offered as untagged.
func TestCreateRuleRetags(t *testing.T) {
s, h := newTestServer(t, "",
fixtureTxn{"checking", "2026-02-10", "ZARA SOFIA", -1000},
)
var p rulePreviewJSON
call(t, h, "POST", "/api/rules/preview", rulePreviewReq{Glob: "*zara*"}, http.StatusOK, &p)
if p.Matches != 1 || p.Candidates != 1 || p.Rows[0].Marker != "▸" {
t.Fatalf("preview = %+v, want the one description matched", p)
}
call(t, h, "POST", "/api/rules", ruleForm{Match: "*ZARA*", Tag: "clothes"}, http.StatusOK, nil)
if r := readRules(t, s).Rule; len(r) != 1 || r[0].Tag != "clothes" {
t.Fatalf("rules.toml = %+v", r)
}
call(t, h, "POST", "/api/rules/preview", rulePreviewReq{}, http.StatusOK, &p)
if p.Candidates != 0 {
t.Errorf("after saving, %d descriptions still untagged", p.Candidates)
}
var list struct{ Rules []ruleRow }
call(t, h, "GET", "/api/rules", nil, http.StatusOK, &list)
if list.Rules[0].Usage != 1 {
t.Errorf("usage = %d, want 1", list.Rules[0].Usage)
}
}
// An edit's preview includes what the rule claims now, and keeps a description
// the new glob would let go of on screen, marked, instead of dropping it.
func TestEditPreviewShowsWhatIsLetGo(t *testing.T) {
_, h := newTestServer(t, lidlRule,
fixtureTxn{"checking", "2026-02-10", "LIDL SOFIA", -1000},
fixtureTxn{"checking", "2026-02-11", "LIDL VARNA", -1000},
)
edit := 0
var p rulePreviewJSON
call(t, h, "POST", "/api/rules/preview", rulePreviewReq{Glob: "*LIDL SOFIA*", Edit: &edit}, http.StatusOK, &p)
if p.Matches != 1 || p.Dropped != 1 {
t.Fatalf("preview = %+v, want 1 match and 1 dropped", p)
}
markers := map[string]string{}
for _, r := range p.Rows {
markers[r.Description] = r.Marker
}
if markers["LIDL SOFIA"] != "▸" || markers["LIDL VARNA"] != "−" {
t.Errorf("markers = %v", markers)
}
}
// A transfer is a pair or nothing: the preview shows a leg with no other side
// as unpaired, and only a matched pair leaves the report, with its fee named.
func TestTransferPairsLeaveTheReportWithTheirFee(t *testing.T) {
_, h := newTestServer(t, "",
fixtureTxn{"checking", "2026-02-01", "WIRE TO SAVINGS", -50000},
fixtureTxn{"savings", "2026-02-02", "WIRE FROM CHECKING", 49500},
fixtureTxn{"checking", "2026-02-20", "WIRE TO SAVINGS", -10000},
)
form := transferForm{
FromAccount: "checking", FromDesc: "*WIRE TO SAVINGS*",
ToAccount: "savings", ToDesc: "*WIRE FROM CHECKING*",
}
var p transferPreviewJSON
call(t, h, "POST", "/api/transfers/preview", form, http.StatusOK, &p)
if p.Pairs != 0 || p.Unpaired != 3 {
t.Fatalf("exact preview = %+v, want nothing paired on a mismatch", p)
}
form.Tolerance = "1.5"
call(t, h, "POST", "/api/transfers/preview", form, http.StatusOK, &p)
if p.Pairs != 1 || p.Unpaired != 1 || p.Fees != "5.00" {
t.Fatalf("tolerant preview = %+v, want 1 pair, 1 unpaired, 5.00 in fees", p)
}
call(t, h, "POST", "/api/transfers", form, http.StatusOK, nil)
var rep reportJSON
call(t, h, "GET", "/api/report?period=all+time", nil, http.StatusOK, &rep)
// The unpaired leg keeps counting; the pair is excluded with its fee.
if len(rep.Rows) != 1 || rep.Rows[0].Out.Text != "100.00" {
t.Errorf("report rows = %+v, want only the unpaired 100.00", rep.Rows)
}
if len(rep.Excluded) != 1 || rep.Excluded[0].Fee == nil || rep.Excluded[0].Fee.Text != "5.00" {
t.Errorf("excluded = %+v, want the 5.00 fee reported", rep.Excluded)
}
var list struct {
Transfers []transferRow
Unpaired int
}
call(t, h, "GET", "/api/transfers", nil, http.StatusOK, &list)
if list.Unpaired != 1 || list.Transfers[0].Paired != 1 || list.Transfers[0].Tolerance != "1.5%" {
t.Errorf("transfer list = %+v", list)
}
}
// The tolerance may be half typed while previewing, but saving refuses it
// rather than writing a silent zero.
func TestBadToleranceIsRefusedOnSave(t *testing.T) {
s, h := newTestServer(t, "")
call(t, h, "POST", "/api/transfers", transferForm{
FromAccount: "checking", FromDesc: "*A*", ToAccount: "savings", ToDesc: "*B*", Tolerance: "1,5",
}, http.StatusBadRequest, nil)
if n := len(readRules(t, s).Transfer); n != 0 {
t.Errorf("%d transfers written", n)
}
}
// The server outlives hand edits to rules.toml. The overview says when the
// file has moved on, and retag re-reads it, as a fresh `money retag` would.
func TestRetagRereadsRulesFromDisk(t *testing.T) {
s, h := newTestServer(t, "",
fixtureTxn{"checking", "2026-02-10", "ZARA", -1000},
)
var o overview
call(t, h, "GET", "/api/overview", nil, http.StatusOK, &o)
if o.Stale {
t.Fatal("stale before anything changed")
}
path := filepath.Join(s.root, config.RulesFile)
if err := os.WriteFile(path, []byte("[[rule]]\nmatch = \"*ZARA*\"\ntag = \"clothes\"\n"), 0o644); err != nil {
t.Fatal(err)
}
call(t, h, "GET", "/api/overview", nil, http.StatusOK, &o)
if !o.Stale {
t.Fatal("a hand edit to rules.toml is not reported")
}
call(t, h, "POST", "/api/retag", struct{}{}, http.StatusOK, nil)
call(t, h, "GET", "/api/overview", nil, http.StatusOK, &o)
if o.Stale {
t.Error("still stale after retag")
}
var txns struct{ Rows []txnRow }
call(t, h, "GET", "/api/transactions", nil, http.StatusOK, &txns)
if txns.Rows[0].Tag != "clothes" {
t.Errorf("tag = %q after retag, want clothes", txns.Rows[0].Tag)
}
}
// (transfer) is a label for a paired leg, never a tag: it is flagged as
// supplied, and never offered for completion.
func TestTransferLabelIsNotATag(t *testing.T) {
_, h := newTestServer(t, `
[[transfer]]
from_account = "checking"
from_desc = "*TO SAVINGS*"
to_account = "savings"
to_desc = "*FROM CHECKING*"
`,
fixtureTxn{"checking", "2026-02-01", "TO SAVINGS", -1000},
fixtureTxn{"savings", "2026-02-01", "FROM CHECKING", 1000},
)
var txns struct{ Rows []txnRow }
call(t, h, "GET", "/api/transactions", nil, http.StatusOK, &txns)
for _, r := range txns.Rows {
if r.Tag != model.TransferTag || !r.Supplied {
t.Errorf("row %+v, want a supplied transfer label", r)
}
}
call(t, h, "GET", "/api/transactions?untagged=1", nil, http.StatusOK, &txns)
if len(txns.Rows) != 0 {
t.Errorf("paired legs listed as untagged: %+v", txns.Rows)
}
var o overview
call(t, h, "GET", "/api/overview", nil, http.StatusOK, &o)
if len(o.Tags) != 0 {
t.Errorf("tags offered = %v, want none", o.Tags)
}
}
func TestPageIsServed(t *testing.T) {
_, h := newTestServer(t, "")
for _, path := range []string{"/", "/app.js", "/style.css"} {
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest("GET", path, nil))
if w.Code != http.StatusOK || w.Body.Len() == 0 {
t.Errorf("GET %s: status %d, %d bytes", path, w.Code, w.Body.Len())
}
}
}
+899
View File
@@ -0,0 +1,899 @@
// The browser side of `money serve`. It renders what the API returns and
// nothing more: amounts arrive formatted, globs are matched on the server, and
// every tag still comes from rules.toml — there is no control here that tags a
// transaction, only the builder that writes a rule for it.
'use strict';
const VIEWS = [
{ id: 'accounts', title: 'Accounts', mount: mountAccounts },
{ id: 'txns', title: 'Transactions', mount: mountTxns },
{ id: 'report', title: 'Report', mount: mountReport },
{ id: 'rule', title: 'Rule builder', mount: mountRuleBuilder },
{ id: 'rules', title: 'Rules', mount: mountRules },
{ id: 'transfer', title: 'Transfer builder', mount: mountTransferBuilder },
{ id: 'transfers', title: 'Transfers', mount: mountTransfers },
];
const state = {
view: 'accounts',
overview: null,
// The scope the transaction list and the report share. The report's period
// lives apart from it on purpose: it narrows the report and nothing else.
filter: { account: '', search: '', untagged: false },
wide: false,
report: { period: '', sort: 'out' },
rule: blankRule(),
ruleSort: 'name',
transfer: blankTransfer(),
status: '',
error: '',
busy: false,
};
function blankRule() {
return { match: '', account: '', tag: '', note: '', editing: null };
}
function blankTransfer() {
return { fromAccount: '', fromDesc: '', toAccount: '', toDesc: '', tolerance: '', note: '' };
}
// --- plumbing -------------------------------------------------------------
// h builds an element. Text always goes in as a text node, never as HTML,
// since descriptions come straight off bank statements.
function h(tag, attrs, ...children) {
const el = document.createElement(tag);
for (const [k, v] of Object.entries(attrs || {})) {
if (v === null || v === undefined || v === false) continue;
if (k.startsWith('on')) el.addEventListener(k.slice(2), v);
else if (k === 'class') el.className = v;
else if (k === 'value') el.value = v;
else el.setAttribute(k, v === true ? '' : v);
}
for (const c of children.flat()) {
if (c === null || c === undefined || c === false) continue;
el.append(c instanceof Node ? c : document.createTextNode(String(c)));
}
return el;
}
async function api(method, path, body) {
const opts = { method, headers: {} };
if (body !== undefined) {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(body);
}
const res = await fetch(path, opts);
const data = await res.json().catch(() => ({ error: `${res.status} ${res.statusText}` }));
if (!res.ok) throw new Error(data.error || `${res.status} ${res.statusText}`);
return data;
}
// latest wraps a loader so only the most recent call's answer is used: typing
// fires one request per keystroke, and they do not always come back in order.
function latest(fn) {
let seq = 0;
return async (...args) => {
const mine = ++seq;
const out = await fn(...args);
return mine === seq ? out : undefined;
};
}
function debounce(fn, ms) {
let t;
return (...args) => {
clearTimeout(t);
t = setTimeout(() => fn(...args), ms);
};
}
function query(params) {
const q = new URLSearchParams();
for (const [k, v] of Object.entries(params)) {
if (v === '' || v === false || v === null || v === undefined) continue;
q.set(k, v === true ? '1' : v);
}
const s = q.toString();
return s ? '?' + s : '';
}
function amount(m) {
return h('td', { class: 'num' + (m.negative ? ' neg' : '') }, m.text);
}
function setStatus(msg) {
state.status = msg;
state.error = '';
renderBanners();
}
function setError(err) {
state.error = err instanceof Error ? err.message : String(err);
renderBanners();
}
function renderBanners() {
const box = document.getElementById('banners');
box.replaceChildren();
const o = state.overview;
if (o && o.stale) {
const text = o.rulesError
? `rules.toml does not load: ${o.rulesError}`
: 'rules.toml has changed on disk since the index was last derived from it.';
box.append(h('div', { class: 'banner warn' }, h('span', {}, text),
o.rulesError ? null : h('button', { type: 'button', onclick: retag }, 'Retag to apply it')));
}
if (state.error) {
box.append(h('div', { class: 'banner error' }, h('span', {}, state.error),
h('button', { type: 'button', class: 'link', onclick: () => setError('') }, 'dismiss')));
} else if (state.status) {
box.append(h('div', { class: 'banner info' }, h('span', {}, state.status),
h('button', { type: 'button', class: 'link', onclick: () => setStatus('') }, 'dismiss')));
}
}
async function loadOverview() {
state.overview = await api('GET', '/api/overview');
renderBanners();
fillDatalists();
}
// The account and tag fields complete natively, from the lists the TUI
// completes against. (transfer) is never among the tags: it is a label.
function fillDatalists() {
const o = state.overview;
if (!o) return;
for (const [id, values] of [['dl-accounts', o.accountSlugs], ['dl-tags', o.tags]]) {
let dl = document.getElementById(id);
if (!dl) {
dl = h('datalist', { id });
document.body.append(dl);
}
dl.replaceChildren(...values.map((v) => h('option', { value: v })));
}
}
function scopeLabel() {
const f = state.filter;
let s = f.account || 'all accounts';
if (f.untagged) s += ' · untagged';
if (f.search) s += ' · /' + f.search;
return s;
}
// --- navigation -----------------------------------------------------------
let current = null; // the mounted view, whose refresh() reloads its data
function renderNav() {
const nav = document.getElementById('nav');
nav.replaceChildren(...VIEWS.map((v, i) => h('a', {
href: '#' + v.id,
class: v.id === state.view ? 'active' : null,
}, h('kbd', {}, String(i + 1)), v.title)));
}
function go(id) {
if (location.hash !== '#' + id) {
location.hash = id; // hashchange calls show()
return;
}
show();
}
function show() {
const id = location.hash.slice(1) || 'accounts';
const view = VIEWS.find((v) => v.id === id) || VIEWS[0];
// Leaving the builder mid-edit abandons the edit, as esc does in the TUI: a
// new rule must not start out as a copy of the one being edited.
if (view.id !== 'rule' && state.rule.editing) state.rule = blankRule();
state.view = view.id;
renderNav();
const main = document.getElementById('main');
main.replaceChildren();
current = view.mount(main) || null;
}
async function refreshAll() {
await loadOverview();
if (current && current.refresh) await current.refresh();
}
// --- import and retag -----------------------------------------------------
async function runImport(force) {
if (state.busy) return;
state.busy = true;
const btn = document.getElementById('import');
btn.disabled = true;
btn.textContent = 'Importing…';
setStatus('importing… parsing PDF statements can take a while');
try {
const res = await api('POST', '/api/import', { force: !!force });
state.status = res.status;
state.error = res.failed ? `${res.failed} file(s) failed to import` : '';
renderBanners();
showImportDetails(res);
await refreshAll();
} catch (e) {
setError(e);
} finally {
state.busy = false;
btn.disabled = false;
btn.textContent = 'Import';
}
}
// showImportDetails lists what each file contributed, like `money import`
// does. Files skipped by checksum are not listed: unchanged is not news.
function showImportDetails(res) {
if (!res.files.length) return;
const items = res.files.map((f) => h('li', {},
f.error ? h('span', { class: 'neg' }, `${f.path}: ${f.error}`)
: `${f.path}: ${f.new} new, ${f.skipped} duplicate (${f.parsed} parsed)`,
(f.warnings || []).map((w) => h('div', { class: 'warn' }, 'warning: ' + w))));
const box = document.getElementById('banners');
box.append(h('div', { class: 'banner info' },
h('ul', { class: 'import-files' }, items),
h('button', { type: 'button', class: 'link', onclick: (e) => e.target.parentElement.remove() }, 'dismiss')));
}
async function retag() {
if (state.busy) return;
try {
const res = await api('POST', '/api/retag', {});
setStatus(res.status);
await refreshAll();
} catch (e) {
setError(e);
}
}
// --- accounts -------------------------------------------------------------
function mountAccounts(main) {
const body = h('div');
main.append(h('h2', {}, 'Accounts'), body);
async function refresh() {
if (!state.overview) await loadOverview();
const o = state.overview;
if (!o.accounts.length) {
// An account.toml is not enough on its own: folders reach the index
// only through an import, so say that rather than show nothing.
body.replaceChildren(h('div', { class: 'panel empty' }, o.configured
? `No accounts imported yet.\n\n${o.configured} account folder(s) configured in ${o.root}.\nPress Import to read their statements.`
: `No account folders found in ${o.root}.\n\nAn account is a folder containing an account.toml.\nCreate one, drop statements in, then press Import.`));
return;
}
body.replaceChildren(h('div', { class: 'panel' }, h('table', {},
h('thead', {}, h('tr', {}, h('th', {}, 'Account'), h('th', {}, 'Slug'),
h('th', { class: 'num' }, 'Balance'), h('th', {}, 'Cur'), h('th', { class: 'num' }, 'Txns'))),
h('tbody', {}, o.accounts.map((a) => h('tr', {
class: 'clickable',
title: 'Show this account\'s transactions',
onclick: () => { state.filter.account = a.slug; go('txns'); },
}, h('td', {}, a.name), h('td', { class: 'muted' }, a.slug), amount(a.balance),
h('td', {}, a.currency), h('td', { class: 'num' }, a.count)))))));
}
refresh().catch(setError);
return { refresh };
}
// --- shared scope toolbar ---------------------------------------------------
function scopeToolbar(onChange) {
const accounts = (state.overview && state.overview.accounts) || [];
const select = h('select', {
'aria-label': 'Account',
onchange: (e) => { state.filter.account = e.target.value; onChange(); },
}, h('option', { value: '' }, 'All accounts'),
accounts.map((a) => h('option', { value: a.slug }, a.slug)));
select.value = state.filter.account;
const search = h('input', {
type: 'search', id: 'search', placeholder: 'Filter by description ( / )',
value: state.filter.search,
oninput: debounce((e) => { state.filter.search = e.target.value.trim(); onChange(); }, 200),
});
const untagged = h('input', {
type: 'checkbox', checked: state.filter.untagged,
onchange: (e) => { state.filter.untagged = e.target.checked; onChange(); },
});
return h('div', { class: 'toolbar' }, select, search,
h('label', { class: 'check', title: 'No tag and not a matched transfer leg (u)' }, untagged, 'Untagged only'));
}
// --- transactions ---------------------------------------------------------
const PAGE = 500;
function mountTxns(main) {
const title = h('h2');
const body = h('div');
let shown = PAGE;
let rows = [];
const wide = h('input', {
type: 'checkbox', checked: state.wide,
onchange: (e) => { state.wide = e.target.checked; draw(); },
});
const toolbar = scopeToolbar(() => { shown = PAGE; refresh(); });
toolbar.append(h('label', { class: 'check', title: 'Also show type and reported balance' }, wide, 'Wide'),
h('button', { type: 'button', onclick: () => go('rule') }, 'New rule…'));
main.append(title, toolbar, body);
const load = latest(() => api('GET', '/api/transactions' + query(state.filter)));
function emptyMessage() {
const f = state.filter;
if (f.search) return `No transactions match /${f.search}.`;
if (f.untagged) return 'Nothing untagged.\n\nEvery transaction here has a tag or belongs to a transfer.';
if (f.account) return `No transactions in ${f.account}.\n\nImport, or choose all accounts.`;
return 'No transactions yet.\n\nPress Import to read your statements.';
}
function draw() {
title.replaceChildren(`Transactions `, h('span', { class: 'sub' }, `· ${scopeLabel()} · ${rows.length} rows`));
if (!rows.length) {
body.replaceChildren(h('div', { class: 'panel empty' }, emptyMessage()));
return;
}
const head = ['Date', 'Account', 'Amount', 'Tag'];
if (state.wide) head.push('Balance', 'Type');
head.push('Description');
const visible = rows.slice(0, shown);
body.replaceChildren(h('div', { class: 'panel' },
h('table', {},
h('thead', {}, h('tr', {}, head.map((c) => h('th', { class: c === 'Amount' || c === 'Balance' ? 'num' : null }, c)))),
h('tbody', {}, visible.map((t) => h('tr', {},
h('td', {}, t.date), h('td', {}, t.account), amount(t.amount),
h('td', { class: t.supplied ? 'muted' : null }, t.tag),
state.wide ? [h('td', { class: 'num' }, t.balance), h('td', { class: 'muted' }, t.type)] : null,
h('td', { class: 'desc' }, t.description))))),
rows.length > shown ? h('div', { class: 'more' },
h('button', { type: 'button', onclick: () => { shown += PAGE; draw(); } },
`Show more (${rows.length - shown} hidden)`)) : null));
}
async function refresh() {
try {
const res = await load();
if (!res) return;
rows = res.rows;
draw();
} catch (e) { setError(e); }
}
refresh();
return { refresh };
}
// --- report ---------------------------------------------------------------
const TRANSFERS_ROW = '(transfers)';
const FEES_ROW = '⤷ fees';
function mountReport(main) {
const title = h('h2');
const body = h('div');
main.append(title, scopeToolbar(() => refresh()), body);
let data = null;
const load = latest(() => api('GET', '/api/report' + query({
...state.filter, period: state.report.period, sort: state.report.sort,
})));
function setPeriod(i) {
if (!data || i < 0 || i >= data.periods.length || i === data.period) return;
state.report.period = data.periods[i].label;
refresh();
}
function draw() {
const period = data.periods[data.period];
title.replaceChildren('Report ', h('span', { class: 'sub' }, `· ${period.name} · ${scopeLabel()}`));
const periods = h('div', { class: 'panel periods' }, h('div', { class: 'head' }, 'Period'),
data.periods.map((p, i) => h('button', {
type: 'button', class: i === data.period ? 'active' : null, title: p.name,
onclick: () => setPeriod(i),
}, p.label)));
let content;
if (!data.rows.length && !data.excluded.length) {
// An empty period is not an empty index, and the default window is the
// one most likely to be empty: last month's statement may not be in yet.
content = h('div', { class: 'panel empty' }, period.bounded && !data.indexEmpty
? `Nothing in ${period.name}.\n\nChoose another period (← →), or import.`
: 'Nothing to report yet.\n\nImport some statements first.');
} else {
const order = data.orders.find((o) => o.name === data.order);
const cols = [['Tag', false], ['Cur', false], ['Out', true], ['In', true], ['Net', true], ['N', true]];
const header = h('tr', {}, cols.map(([c, num]) => {
const o = data.orders.find((x) => x.column === c);
const marked = order && order.column === c;
return h('th', {
class: [num ? 'num' : '', o ? 'sortable' : ''].join(' ').trim() || null,
title: o ? 'Sort: ' + o.label : null,
onclick: o ? () => { state.report.sort = o.name; refresh(); } : null,
}, c + (marked ? (order.ascending ? ' ▴' : ' ▾') : ''));
}));
const row = (cls, tag, r, n) => h('tr', { class: cls }, h('td', {}, tag), h('td', {}, r.currency),
amount(r.out), amount(r.in), amount(r.net), h('td', { class: 'num' }, n));
const blank = { text: '', negative: false };
content = h('div', { class: 'panel' }, h('table', {}, h('thead', {}, header), h('tbody', {},
data.rows.map((r) => row(r.tag === '(untagged)' ? 'muted' : null, r.tag, r, r.count)),
data.totals.map((r) => row('total', 'TOTAL', r, '')),
// What the report held out, so it agrees with the balances: the legs
// moved between accounts, and any fee a tolerant definition admitted.
data.excluded.map((x, i) => [
row('excluded' + (i === 0 ? ' first' : ''), TRANSFERS_ROW, x, x.legs),
x.fee ? row('excluded', FEES_ROW, { currency: x.currency, out: x.fee, in: blank, net: x.feeNet }, x.pairs) : null,
]))));
}
body.replaceChildren(h('div', { class: 'report' }, periods, content));
}
async function refresh() {
try {
const res = await load();
if (!res) return;
data = res;
state.report.period = data.periods[data.period].label;
draw();
} catch (e) { setError(e); }
}
refresh();
return {
refresh,
key(e, typing) {
if (typing || !data || e.ctrlKey || e.metaKey || e.altKey) return false;
if (e.key === 'ArrowLeft') { setPeriod(data.period - 1); return true; }
if (e.key === 'ArrowRight') { setPeriod(data.period + 1); return true; }
if (e.key === 's') {
const i = data.orders.findIndex((o) => o.name === data.order);
state.report.sort = data.orders[(i + 1) % data.orders.length].name;
refresh();
return true;
}
return false;
},
};
}
// --- form helpers ---------------------------------------------------------
function field(label, input, hint) {
return h('div', { class: 'field' }, h('label', { for: input.id }, label), input,
hint ? h('div', { class: 'hint' }, hint) : null);
}
function textInput(id, value, placeholder, oninput, list) {
return h('input', {
type: 'text', id, value, placeholder, list, autocomplete: 'off', spellcheck: 'false', oninput,
});
}
// --- rule builder ---------------------------------------------------------
function mountRuleBuilder(main) {
const r = state.rule;
const editing = r.editing;
// A new rule starts on the account being browsed, as in the TUI.
if (!editing && !r.account && state.filter.account) r.account = state.filter.account;
const summary = h('div', { class: 'summary' });
const preview = h('div', { class: 'panel preview' });
const update = (k) => (e) => { r[k] = e.target.value; refreshPreview(); };
const inputs = {
match: textInput('rule-match', r.match, '*LIDL*', update('match')),
account: textInput('rule-account', r.account, 'blank = every account', update('account'), 'dl-accounts'),
tag: textInput('rule-tag', r.tag, 'groceries', update('tag'), 'dl-tags'),
note: textInput('rule-note', r.note, 'optional', update('note')),
};
const save = h('button', { type: 'submit', class: 'primary' }, editing ? 'Save changes' : 'Save rule');
const form = h('form', {
class: 'panel form',
onsubmit: (e) => { e.preventDefault(); submit(); },
},
field('Glob', inputs.match, 'vs. the description · * any run, ? one character'),
// The form has no type field, so a rule that sets one says it is kept
// rather than looking broader than it is.
editing && editing.expect.type ? h('div', { class: 'kept' }, `+ type:${editing.expect.type} · kept`) : null,
field('Account', inputs.account, 'blank = all accounts'),
field('Tag', inputs.tag, 'applied to matches'),
field('Note', inputs.note, 'why this rule exists'),
summary,
h('div', { class: 'buttons' }, save,
editing ? h('button', { type: 'button', onclick: () => { state.rule = blankRule(); go('rules'); } }, 'Cancel') : null));
main.append(
h('h2', {}, editing ? `Editing rule ${editing.pos + 1} ` : 'Rule builder ',
h('span', { class: 'sub' }, editing ? '· rewrites it in rules.toml' : '· writes to rules.toml')),
h('div', { class: 'builder' }, form, preview));
const load = latest(() => api('POST', '/api/rules/preview', {
glob: r.match, account: r.account, sort: state.ruleSort,
edit: editing ? editing.pos : null,
}));
async function refreshNow() {
try {
const res = await load();
if (!res) return;
draw(res);
} catch (e) { setError(e); }
}
const refreshPreview = debounce(refreshNow, 120);
function draw(res) {
let text = `${res.matches} of ${res.candidates} descriptions match`;
if (!r.match.trim() && !editing) text = `${res.candidates} untagged descriptions`;
if (res.dropped) text += `\n${res.dropped} no longer claimed`;
summary.textContent = text;
const byCount = state.ruleSort === 'count';
const toggle = () => { state.ruleSort = byCount ? 'name' : 'count'; refreshNow(); };
const descTitle = (editing ? 'Description' : 'Untagged description') + (byCount ? '' : ' ↓');
if (!res.rows.length) {
preview.replaceChildren(h('div', { class: 'empty' }, r.match.trim()
? 'The glob matches nothing still waiting for a rule.'
: 'Nothing untagged — every transaction has a tag or belongs to a transfer.'));
return;
}
preview.replaceChildren(h('table', {},
h('thead', {}, h('tr', {}, h('th', {}, ''),
h('th', { class: 'sortable', onclick: toggle, title: 'Sort by name / by count (ctrl+s)' }, descTitle),
h('th', { class: 'num sortable', onclick: toggle, title: 'Sort by name / by count (ctrl+s)' }, 'N' + (byCount ? ' ↓' : '')))),
h('tbody', {}, res.rows.map((row) => h('tr', {
class: 'clickable',
title: 'Use this description as the glob',
onclick: () => {
// Collapsed as the engine normalises it, or a doubled space on the
// statement would make a glob that never matches.
inputs.match.value = r.match = '*' + row.description.trim().split(/\s+/).join(' ') + '*';
inputs.match.focus();
refreshNow();
},
}, h('td', { class: 'mark' + (row.marker === '−' ? ' neg' : '') }, row.marker),
h('td', { class: 'desc' }, row.description), h('td', { class: 'num' }, row.count))))));
}
async function submit() {
save.disabled = true;
try {
const form = { match: r.match, account: r.account, tag: r.tag, note: r.note };
if (editing) {
const res = await api('PUT', `/api/rules/${editing.pos}`, { rule: form, expect: editing.expect });
state.rule = blankRule();
setStatus(res.status);
await loadOverview();
// An edit is a round trip from the rules screen, so it ends there.
go('rules');
return;
}
const res = await api('POST', '/api/rules', form);
// The account stays: the next rule is usually for the same one.
Object.assign(r, { match: '', tag: '', note: '' });
inputs.match.value = inputs.tag.value = inputs.note.value = '';
inputs.match.focus();
setStatus(res.status);
await loadOverview();
await refreshNow();
} catch (e) {
setError(e);
} finally {
save.disabled = false;
}
}
inputs.match.focus();
refreshNow();
return {
refresh: refreshNow,
form: true,
key(e) {
if (e.key === 's' && e.ctrlKey) {
state.ruleSort = state.ruleSort === 'count' ? 'name' : 'count';
refreshNow();
return true;
}
if (e.key === 'Escape' && editing) {
state.rule = blankRule();
go('rules');
return true;
}
return false;
},
};
}
// --- rules ----------------------------------------------------------------
function ruleExpect(r) {
return { match: r.match, type: r.type, account: r.account, tag: r.tag, note: r.note };
}
function mountRules(main) {
const title = h('h2');
const body = h('div');
const prune = h('button', { type: 'button', class: 'danger' }, 'Prune unused');
main.append(title, h('div', { class: 'toolbar' },
h('button', { type: 'button', onclick: () => go('rule') }, 'New rule…'),
prune,
h('button', { type: 'button', onclick: () => refresh().then(() => setStatus('counts refreshed')) }, 'Refresh counts')),
body);
let rules = [];
async function del(positions, question) {
if (!confirm(question)) return;
try {
const res = await api('POST', '/api/rules/delete', {
positions, expect: positions.map((p) => ruleExpect(rules[p])),
});
setStatus(res.status);
await refreshAll();
} catch (e) { setError(e); }
}
function edit(r) {
state.rule = {
match: r.match, account: r.account, tag: r.tag, note: r.note,
editing: { pos: r.pos, expect: ruleExpect(r) },
};
go('rule');
}
async function refresh() {
try {
rules = (await api('GET', '/api/rules')).rules;
} catch (e) { setError(e); return; }
const unused = rules.filter((r) => r.usage === 0);
title.replaceChildren('Rules ', h('span', { class: 'sub' }, unused.length
? `· ${rules.length} rules · ${unused.length} match nothing`
: `· ${rules.length} rules, all in use`));
prune.disabled = !unused.length;
prune.onclick = () => del(unused.map((r) => r.pos), `Delete all ${unused.length} rules that match nothing?`);
if (!rules.length) {
body.replaceChildren(h('div', { class: 'panel empty' }, 'No rules yet.\n\nBuild one, or write rules.toml by hand.'));
return;
}
// File order, numbered as in rules.toml. Precedence is not read off this:
// the most specific rule wins wherever it sits.
body.replaceChildren(h('div', { class: 'panel' }, h('table', {},
h('thead', {}, h('tr', {}, h('th', { class: 'num' }, '#'), h('th', {}, ''), h('th', {}, 'Pattern'),
h('th', {}, 'Account'), h('th', {}, 'Tag'), h('th', { class: 'num', title: 'Transactions this rule wins' }, 'Txns'),
h('th', {}, 'Note'), h('th', {}, ''))),
h('tbody', {}, rules.map((r) => h('tr', {},
h('td', { class: 'num muted' }, r.pos + 1),
h('td', { class: 'mark neg', title: r.usage ? null : 'Claims nothing: matches nothing, or a more specific rule wins' }, r.usage ? '' : '✗'),
h('td', { class: 'desc', style: 'font-family: var(--mono)' }, r.pattern),
h('td', { class: r.account ? null : 'muted' }, r.account || '(all)'),
h('td', {}, r.tag), h('td', { class: 'num' }, r.usage),
h('td', { class: 'desc muted' }, r.note),
h('td', { class: 'actions' },
h('button', { type: 'button', onclick: () => edit(r) }, 'Edit'), ' ',
h('button', {
type: 'button', class: 'danger',
onclick: () => del([r.pos], `Delete rule ${r.pos + 1} (${r.pattern} → ${r.tag}), used by ${r.usage} transactions?`),
}, 'Delete'))))))));
}
refresh();
return { refresh };
}
// --- transfer builder -----------------------------------------------------
function mountTransferBuilder(main) {
const t = state.transfer;
const summary = h('div', { class: 'summary' });
const preview = h('div', { class: 'panel preview' });
const update = (k) => (e) => { t[k] = e.target.value; refreshPreview(); };
const inputs = {
fromAccount: textInput('tr-from', t.fromAccount, 'nlb', update('fromAccount'), 'dl-accounts'),
fromDesc: textInput('tr-from-desc', t.fromDesc, '*TO REVOLUT*', update('fromDesc')),
toAccount: textInput('tr-to', t.toAccount, 'revolut', update('toAccount'), 'dl-accounts'),
toDesc: textInput('tr-to-desc', t.toDesc, '*FROM NLB*', update('toDesc')),
tolerance: textInput('tr-tol', t.tolerance, '0', update('tolerance')),
note: textInput('tr-note', t.note, 'optional', update('note')),
};
const save = h('button', { type: 'submit', class: 'primary' }, 'Save transfer');
main.append(
h('h2', {}, 'Transfer builder ', h('span', { class: 'sub' }, '· writes to rules.toml')),
h('div', { class: 'builder' },
h('form', { class: 'panel form', onsubmit: (e) => { e.preventDefault(); submit(); } },
field('From account', inputs.fromAccount, 'money leaves here'),
field('From desc', inputs.fromDesc, 'glob vs. the leaving leg'),
field('To account', inputs.toAccount, 'money arrives here'),
field('To desc', inputs.toDesc, 'glob vs. the arriving leg'),
field('Tolerance %', inputs.tolerance, '0 = amounts must match exactly; only for a route that charges a fee'),
field('Note', inputs.note, 'why this transfer exists'),
summary,
h('div', { class: 'buttons' }, save)),
preview));
const load = latest(() => api('POST', '/api/transfers/preview', t));
async function refreshNow() {
try {
const res = await load();
if (!res) return;
draw(res);
} catch (e) { setError(e); }
}
const refreshPreview = debounce(refreshNow, 120);
function draw(res) {
let text = res.pairs || res.unpaired ? `${res.pairs} pairs · ${res.unpaired} unpaired` : 'nothing matches yet';
// Fees are why the tolerance exists, so the preview says what it admits.
if (res.fees) text += ` · ${res.fees} in fees`;
summary.textContent = text;
if (!res.rows.length) {
preview.replaceChildren(h('div', { class: 'empty' },
'Fill in either side to see which legs it catches.\n▸ a matched pair · ⚠ a leg with no other side'));
return;
}
preview.replaceChildren(h('table', {},
h('thead', {}, h('tr', {}, h('th', {}, ''), h('th', {}, 'Date'), h('th', { class: 'num' }, 'Amount'),
h('th', {}, 'Movement'), h('th', {}, 'Description'))),
h('tbody', {}, res.rows.map((row) => h('tr', {},
h('td', { class: 'mark' + (row.marker === '⚠' ? ' warn' : '') }, row.marker),
h('td', {}, row.date), h('td', { class: 'num' }, row.amount),
h('td', {}, row.movement), h('td', { class: 'desc' }, row.description))))));
}
async function submit() {
save.disabled = true;
try {
const res = await api('POST', '/api/transfers', t);
// The accounts stay — the next definition is usually the same route the
// other way. The tolerance goes with the globs: carried over silently it
// would loosen a route that never asked for one.
for (const k of ['fromDesc', 'toDesc', 'tolerance', 'note']) {
t[k] = '';
inputs[k].value = '';
}
inputs.fromDesc.focus();
setStatus(res.status);
await loadOverview();
await refreshNow();
} catch (e) {
setError(e);
} finally {
save.disabled = false;
}
}
inputs.fromAccount.focus();
refreshNow();
return { refresh: refreshNow, form: true };
}
// --- transfers ------------------------------------------------------------
function transferExpect(t) {
return {
fromAccount: t.fromAccount, fromDesc: t.fromDesc, toAccount: t.toAccount,
toDesc: t.toDesc, tolerancePct: t.tolerancePct, note: t.note,
};
}
function mountTransfers(main) {
const title = h('h2');
const body = h('div');
const prune = h('button', { type: 'button', class: 'danger' }, 'Prune unmatched');
main.append(title, h('div', { class: 'toolbar' },
h('button', { type: 'button', onclick: () => go('transfer') }, 'New transfer…'),
prune,
h('button', { type: 'button', onclick: () => refresh().then(() => setStatus('pairing refreshed')) }, 'Refresh pairing')),
body);
let defs = [];
async function del(positions, question) {
if (!confirm(question)) return;
try {
const res = await api('POST', '/api/transfers/delete', {
positions, expect: positions.map((p) => transferExpect(defs[p])),
});
setStatus(res.status);
await refreshAll();
} catch (e) { setError(e); }
}
async function refresh() {
let res;
try {
res = await api('GET', '/api/transfers');
} catch (e) { setError(e); return; }
defs = res.transfers;
title.replaceChildren('Transfers ', h('span', { class: 'sub' }, res.unpaired
? `· ${defs.length} definitions · ${res.unpaired} leg(s) unpaired`
: `· ${defs.length} definitions · every leg paired`));
// Only definitions catching nothing at all are pruned. One with unpaired
// legs is doing something, and deleting it would hide the problem.
const unused = defs.filter((d) => d.paired === 0 && d.orphaned === 0);
prune.disabled = !unused.length;
prune.onclick = () => del(unused.map((d) => d.pos), `Delete all ${unused.length} transfers that match nothing?`);
if (!defs.length) {
body.replaceChildren(h('div', { class: 'panel empty' },
'No transfers yet.\n\nA transfer names both legs of money moved between your own accounts,\n' +
'so the report can leave the pair out instead of counting it as spending.'));
return;
}
body.replaceChildren(h('div', { class: 'panel' }, h('table', {},
h('thead', {}, h('tr', {}, h('th', { class: 'num' }, '#'), h('th', {}, ''), h('th', {}, 'From'), h('th', {}, 'To'),
h('th', { class: 'num' }, 'Pairs'), h('th', { class: 'num' }, 'Unpaired'), h('th', { class: 'num' }, 'Tol'),
h('th', {}, 'Note'), h('th', {}, ''))),
h('tbody', {}, defs.map((d) => {
let mark = '', cls = 'mark', tip = null;
if (d.orphaned > 0) [mark, cls, tip] = ['⚠', 'mark warn', 'Catches legs it cannot pair'];
else if (d.paired === 0) [mark, cls, tip] = ['✗', 'mark neg', 'Matches nothing at all'];
return h('tr', {},
h('td', { class: 'num muted' }, d.pos + 1),
h('td', { class: cls, title: tip }, mark),
h('td', {}, d.fromAccount, ' ', h('span', { style: 'font-family: var(--mono)' }, d.fromDesc)),
h('td', {}, d.toAccount, ' ', h('span', { style: 'font-family: var(--mono)' }, d.toDesc)),
h('td', { class: 'num' }, d.paired),
h('td', { class: 'num' + (d.orphaned ? ' warn' : '') }, d.orphaned),
h('td', { class: 'num' }, d.tolerance),
h('td', { class: 'desc muted' }, d.note),
h('td', { class: 'actions' }, h('button', {
type: 'button', class: 'danger',
onclick: () => del([d.pos], `Delete transfer ${d.pos + 1} (${d.fromAccount} → ${d.toAccount}), ${d.paired} matched?`),
}, 'Delete')));
})))));
}
refresh();
return { refresh };
}
// --- keyboard -------------------------------------------------------------
// The TUI's keys, where they do not fight the browser. A builder is a form,
// so outside of chords its keys belong to the field being typed in — exactly
// the rule the TUI keeps.
document.addEventListener('keydown', (e) => {
const t = e.target;
const typing = t instanceof HTMLInputElement || t instanceof HTMLSelectElement || t instanceof HTMLTextAreaElement;
if (current && current.key && current.key(e, typing)) {
e.preventDefault();
return;
}
if (typing || e.ctrlKey || e.metaKey || e.altKey) return;
if (e.key >= '1' && e.key <= String(VIEWS.length)) {
go(VIEWS[Number(e.key) - 1].id);
} else if (e.key === '/') {
if (state.view !== 'txns' && state.view !== 'report') go('txns');
setTimeout(() => { const s = document.getElementById('search'); if (s) s.focus(); });
} else if (e.key === 'u') {
state.filter.untagged = !state.filter.untagged;
if (state.view === 'txns' || state.view === 'report') show();
else go('txns');
} else if (e.key === 'a') {
state.filter.account = '';
if (state.view === 'txns' || state.view === 'report') show();
} else if (e.key === 'i') {
runImport(false);
} else if (e.key === 'r') {
retag();
} else {
return;
}
e.preventDefault();
});
document.getElementById('import').addEventListener('click', (e) => runImport(e.shiftKey));
document.getElementById('import').title += ' (shift-click re-parses unchanged files too)';
document.getElementById('retag').addEventListener('click', retag);
window.addEventListener('hashchange', show);
loadOverview().catch(setError).finally(show);
+22
View File
@@ -0,0 +1,22 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>money</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header class="top">
<a class="brand" href="#accounts">money</a>
<nav id="nav" aria-label="Screens"></nav>
<div class="actions">
<button type="button" id="retag" title="Re-apply rules.toml: retag everything and re-pair transfers">Retag</button>
<button type="button" id="import" class="primary" title="Import new statements from every account folder">Import</button>
</div>
</header>
<div id="banners"></div>
<main id="main"></main>
<script src="app.js"></script>
</body>
</html>
+189
View File
@@ -0,0 +1,189 @@
:root {
--bg: #f7f7f5;
--surface: #ffffff;
--text: #1d1d1f;
--muted: #6b6b70;
--border: #e2e2de;
--accent: #4b46c8;
--accent-text: #ffffff;
--accent-soft: #ecebfb;
--negative: #b3261e;
--positive: #1d7a3a;
--warn: #9a5b00;
--warn-soft: #fff4e0;
--error-soft: #fdecea;
--row-hover: #f2f2ef;
--mono: ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace;
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #161618;
--surface: #1e1e21;
--text: #e8e8ea;
--muted: #9a9aa2;
--border: #333338;
--accent: #8f8cf0;
--accent-text: #111114;
--accent-soft: #2a2950;
--negative: #f2867d;
--positive: #6fcf8e;
--warn: #f0b45a;
--warn-soft: #3a2c14;
--error-soft: #3d1c1a;
--row-hover: #26262a;
color-scheme: dark;
}
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font: 14px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif;
}
button, input, select { font: inherit; color: inherit; }
.top {
display: flex;
align-items: center;
gap: 16px;
padding: 10px 16px;
background: var(--surface);
border-bottom: 1px solid var(--border);
flex-wrap: wrap;
position: sticky;
top: 0;
z-index: 2;
}
.brand { font-weight: 700; font-size: 16px; color: var(--text); text-decoration: none; }
#nav { display: flex; gap: 2px; flex-wrap: wrap; flex: 1; }
#nav a {
padding: 5px 10px;
border-radius: 6px;
color: var(--muted);
text-decoration: none;
white-space: nowrap;
}
#nav a:hover { color: var(--text); background: var(--row-hover); }
#nav a.active { color: var(--accent); background: var(--accent-soft); font-weight: 600; }
#nav kbd { font-family: var(--mono); font-size: 11px; opacity: .6; margin-right: 4px; }
.actions { display: flex; gap: 8px; }
button {
padding: 5px 12px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--surface);
cursor: pointer;
}
button:hover:not(:disabled) { border-color: var(--muted); }
button:disabled { opacity: .5; cursor: default; }
button.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
button.danger { color: var(--negative); }
button.link { border: none; background: none; padding: 0 4px; color: var(--accent); }
#banners { padding: 0 16px; }
.banner {
margin-top: 10px;
padding: 8px 12px;
border-radius: 6px;
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
white-space: pre-wrap;
}
.banner.info { background: var(--accent-soft); }
.banner.warn { background: var(--warn-soft); color: var(--warn); }
.banner.error { background: var(--error-soft); color: var(--negative); }
.banner button { flex: none; }
main { padding: 16px; max-width: 1400px; }
h2 { font-size: 16px; margin: 0 0 12px; font-weight: 600; }
h2 .sub { color: var(--muted); font-weight: 400; }
.toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 12px; }
.toolbar input[type=search] { min-width: 220px; }
input[type=text], input[type=search], select {
padding: 5px 8px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface);
}
input:focus, select:focus { outline: 2px solid var(--accent); outline-offset: -1px; border-color: transparent; }
label.check { display: inline-flex; gap: 6px; align-items: center; cursor: pointer; }
.panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
overflow: auto;
}
table { border-collapse: collapse; width: 100%; }
th, td { padding: 5px 10px; text-align: left; white-space: nowrap; }
th {
position: sticky;
top: 0;
background: var(--surface);
border-bottom: 1px solid var(--border);
font-weight: 600;
z-index: 1;
}
tbody tr:hover { background: var(--row-hover); }
tbody tr.clickable { cursor: pointer; }
td.num, th.num { text-align: right; font-family: var(--mono); font-variant-numeric: tabular-nums; }
td.desc { white-space: normal; min-width: 240px; }
td.mark { width: 1em; text-align: center; padding-right: 0; }
.neg { color: var(--negative); }
.muted { color: var(--muted); }
.warn { color: var(--warn); }
th.sortable { cursor: pointer; user-select: none; }
th.sortable:hover { color: var(--accent); }
tr.total td { font-weight: 600; border-top: 1px solid var(--border); }
tr.excluded td { color: var(--muted); }
tr.excluded.first td { border-top: 1px dashed var(--border); }
td.actions { text-align: right; }
td.actions button { padding: 1px 8px; font-size: 12px; }
.empty { padding: 32px 16px; color: var(--muted); white-space: pre-line; text-align: center; }
.more { padding: 10px; text-align: center; }
.report { display: grid; grid-template-columns: 220px 1fr; gap: 12px; align-items: start; }
.periods { padding: 6px; }
.periods button {
display: block;
width: 100%;
text-align: left;
border: none;
background: none;
padding: 4px 8px;
border-radius: 5px;
color: var(--muted);
}
.periods button:hover { background: var(--row-hover); color: var(--text); }
.periods button.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
.periods .head { font-weight: 600; padding: 4px 8px; }
.builder { display: grid; grid-template-columns: 320px 1fr; gap: 16px; align-items: start; }
.form { padding: 14px; display: flex; flex-direction: column; gap: 12px; }
.field label { display: block; font-weight: 600; margin-bottom: 3px; }
.field input { width: 100%; font-family: var(--mono); }
.field .hint { color: var(--muted); font-size: 12px; margin-top: 2px; }
.form .summary { font-weight: 600; white-space: pre-line; }
.form .buttons { display: flex; gap: 8px; }
.kept { font-size: 12px; color: var(--muted); font-family: var(--mono); }
.preview { max-height: calc(100vh - 170px); }
.import-files { margin-top: 6px; font-size: 13px; }
.import-files li { font-family: var(--mono); }
@media (max-width: 760px) {
.report, .builder { grid-template-columns: 1fr; }
.periods { display: flex; flex-wrap: wrap; gap: 2px; }
.periods button { width: auto; }
.periods .head { width: 100%; }
.toolbar input[type=search] { min-width: 0; flex: 1; }
.preview { max-height: none; }
}