From 6faf99719ad9084e418714ae1f452720358b65a9 Mon Sep 17 00:00:00 2001 From: Nikola Petrov Date: Fri, 2 Oct 2026 09:48:56 +0200 Subject: [PATCH] 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 --- CLAUDE.md | 23 + README.md | 43 ++ cmd/money/main.go | 49 ++ internal/web/server.go | 1180 ++++++++++++++++++++++++++++++++ internal/web/server_test.go | 388 +++++++++++ internal/web/static/app.js | 899 ++++++++++++++++++++++++ internal/web/static/index.html | 22 + internal/web/static/style.css | 189 +++++ 8 files changed, 2793 insertions(+) create mode 100644 internal/web/server.go create mode 100644 internal/web/server_test.go create mode 100644 internal/web/static/app.js create mode 100644 internal/web/static/index.html create mode 100644 internal/web/static/style.css diff --git a/CLAUDE.md b/CLAUDE.md index 116f4f4..a7facef 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/README.md b/README.md index 0ee1253..a158a91 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/money/main.go b/cmd/money/main.go index 2214490..40c6c8d 100644 --- a/cmd/money/main.go +++ b/cmd/money/main.go @@ -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] [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 +} diff --git a/internal/web/server.go b/internal/web/server.go new file mode 100644 index 0000000..5d0eae3 --- /dev/null +++ b/internal/web/server.go @@ -0,0 +1,1180 @@ +// Package web serves the TUI's screens over HTTP: a JSON API over the index +// and rules.toml, and a single page that drives it. It holds no logic of its +// own about money — every number is formatted, every glob matched and every +// pair decided by the same packages the TUI and the CLI use, so the browser +// never does arithmetic on an amount or re-implements the matcher. +package web + +import ( + "embed" + "encoding/json" + "errors" + "fmt" + "io/fs" + "net/http" + "slices" + "sort" + "strconv" + "strings" + "sync" + "time" + + "git.petrovv.com/nikola/money/internal/config" + "git.petrovv.com/nikola/money/internal/glob" + "git.petrovv.com/nikola/money/internal/importer" + "git.petrovv.com/nikola/money/internal/model" + "git.petrovv.com/nikola/money/internal/report" + "git.petrovv.com/nikola/money/internal/rules" + "git.petrovv.com/nikola/money/internal/store" + "git.petrovv.com/nikola/money/internal/transfers" +) + +//go:embed static +var static embed.FS + +// Server is the long-lived counterpart of the TUI's Model. +// +// Unlike the TUI it outlives any one edit of rules.toml by hand, so retag and +// import re-read that file (and import the account folders) before running, +// exactly as a fresh `money retag` or `money import` would. Between those, the +// engines are what the index was last derived from, which is what every screen +// must describe; Overview.Stale says when the file on disk has moved on. +type Server struct { + root string + now func() time.Time + + // mu serialises writers — anything touching rules.toml or the index — + // against everything else. Readers share it, so browsing stays concurrent + // while an import holds the index. + mu sync.RWMutex + db *store.DB + accounts []*config.Account + engine *rules.Engine + links *transfers.Engine +} + +// New builds a server over an opened index and the config loaded from root. +func New(root string, db *store.DB, accounts []*config.Account, engine *rules.Engine, + links *transfers.Engine) *Server { + return &Server{root: root, now: time.Now, db: db, accounts: accounts, engine: engine, links: links} +} + +// Handler routes the API and the page. +func (s *Server) Handler() http.Handler { + mux := http.NewServeMux() + page, err := fs.Sub(static, "static") + if err != nil { + panic(err) // the embed directive guarantees the directory exists + } + mux.Handle("GET /", http.FileServerFS(page)) + + mux.HandleFunc("GET /api/overview", s.read(s.overview)) + mux.HandleFunc("GET /api/transactions", s.read(s.transactions)) + mux.HandleFunc("GET /api/report", s.read(s.report)) + + mux.HandleFunc("GET /api/rules", s.read(s.ruleList)) + mux.HandleFunc("POST /api/rules/preview", s.read(s.rulePreview)) + mux.HandleFunc("POST /api/rules", s.write(s.createRule)) + mux.HandleFunc("PUT /api/rules/{pos}", s.write(s.editRule)) + mux.HandleFunc("POST /api/rules/delete", s.write(s.deleteRules)) + + mux.HandleFunc("GET /api/transfers", s.read(s.transferList)) + mux.HandleFunc("POST /api/transfers/preview", s.read(s.transferPreview)) + mux.HandleFunc("POST /api/transfers", s.write(s.createTransfer)) + mux.HandleFunc("POST /api/transfers/delete", s.write(s.deleteTransfers)) + + mux.HandleFunc("POST /api/import", s.write(s.runImport)) + mux.HandleFunc("POST /api/retag", s.write(s.retag)) + return mux +} + +// apiError is an error with the HTTP status it should be answered with. Any +// other error is a 500; the message is shown either way, since it names the +// file or row at fault and that is what makes it actionable. +type apiError struct { + code int + msg string +} + +func (e *apiError) Error() string { return e.msg } + +func badRequest(format string, args ...any) error { + return &apiError{http.StatusBadRequest, fmt.Sprintf(format, args...)} +} + +// staleRules is the answer to an edit aimed at a rule the page saw but the file +// no longer holds in that position. Positions are what rules.toml is edited by, +// so acting on one that moved would rewrite or delete a different rule. +func staleRules() error { + return &apiError{http.StatusConflict, + "rules.toml has changed since this page loaded it; reload and try again"} +} + +type handler func(r *http.Request) (any, error) + +func (s *Server) read(h handler) http.HandlerFunc { return s.serve(h, false) } +func (s *Server) write(h handler) http.HandlerFunc { return s.serve(h, true) } + +func (s *Server) serve(h handler, exclusive bool) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // There is no authentication, so a body is only accepted as JSON: a + // cross-site form cannot send that without a preflight, which nothing + // here answers, so another page open in the browser cannot rewrite + // rules.toml on the user's behalf. + if r.Method != http.MethodGet && + !strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") { + writeJSON(w, http.StatusUnsupportedMediaType, + map[string]string{"error": "requests must be sent as application/json"}) + return + } + if exclusive { + s.mu.Lock() + } else { + s.mu.RLock() + } + v, err := h(r) + if exclusive { + s.mu.Unlock() + } else { + s.mu.RUnlock() + } + if err != nil { + code := http.StatusInternalServerError + var ae *apiError + if errors.As(err, &ae) { + code = ae.code + } + writeJSON(w, code, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, v) + } +} + +func writeJSON(w http.ResponseWriter, code int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(code) + json.NewEncoder(w).Encode(v) +} + +func decode(r *http.Request, v any) error { + dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 1<<20)) + dec.DisallowUnknownFields() + if err := dec.Decode(v); err != nil { + return badRequest("bad request body: %v", err) + } + return nil +} + +// status is the reply to every write: one line saying what happened, as the +// TUI's status bar would. +type status struct { + Status string `json:"status"` +} + +// money is an amount ready to show. Formatting happens here, never in the +// browser, so minor units stay integers end to end. +type money struct { + Text string `json:"text"` + Negative bool `json:"negative"` +} + +func formatMoney(minor int64, digits int) money { + return money{Text: model.FormatMinor(minor, digits), Negative: minor < 0} +} + +// --- overview ------------------------------------------------------------- + +type accountRow struct { + Slug string `json:"slug"` + Name string `json:"name"` + Balance money `json:"balance"` + Currency string `json:"currency"` + Count int `json:"count"` +} + +type overview struct { + Root string `json:"root"` + // Configured is how many account folders are on disk, which the empty + // accounts screen needs: an account.toml is not enough until an import. + Configured int `json:"configured"` + Accounts []accountRow `json:"accounts"` + // AccountSlugs and Tags are what the builders' fields complete against. + AccountSlugs []string `json:"accountSlugs"` + Tags []string `json:"tags"` + // Stale reports that rules.toml on disk no longer says what the index was + // derived from — it was edited by hand — so the page can offer a retag + // instead of quietly describing rules that are not the ones in force. + Stale bool `json:"stale"` + RulesErr string `json:"rulesError,omitempty"` +} + +func (s *Server) overview(*http.Request) (any, error) { + accounts, err := s.db.Accounts() + if err != nil { + return nil, err + } + out := overview{Root: s.root, Configured: len(s.accounts), Accounts: []accountRow{}} + for _, a := range accounts { + bal, err := s.db.Balance(a.ID) + if err != nil { + return nil, err + } + n, err := s.db.Count(a.ID) + if err != nil { + return nil, err + } + out.Accounts = append(out.Accounts, accountRow{ + Slug: a.Slug, Name: a.Name, Balance: formatMoney(bal, a.MinorDigits), + Currency: a.Currency, Count: n, + }) + } + if out.AccountSlugs, err = s.accountSlugs(); err != nil { + return nil, err + } + if out.Tags, err = s.knownTags(); err != nil { + return nil, err + } + onDisk, err := config.LoadRules(s.root) + if err != nil { + out.Stale, out.RulesErr = true, err.Error() + } else { + out.Stale = !slices.Equal(onDisk.Rule, s.engine.Rules()) || + !slices.Equal(onDisk.Transfer, s.links.Transfers()) + } + return out, nil +} + +// accountSlugs lists every account worth completing: the folders on disk and +// whatever the index already holds. +func (s *Server) accountSlugs() ([]string, error) { + configured := make([]string, 0, len(s.accounts)) + for _, a := range s.accounts { + configured = append(configured, a.Slug) + } + accounts, err := s.db.Accounts() + if err != nil { + return nil, err + } + imported := make([]string, 0, len(accounts)) + for _, a := range accounts { + imported = append(imported, a.Slug) + } + return sortedSet(configured, imported), nil +} + +func (s *Server) knownAccount(slug string) (bool, error) { + slugs, err := s.accountSlugs() + return slices.Contains(slugs, slug), err +} + +// knownTags is every tag in use plus every tag a rule names, so a tag is +// completable from the moment a rule mentions it. model.TransferTag is never +// among them: it is a label, not a tag, and nothing may be built from it. +func (s *Server) knownTags() ([]string, error) { + tagged, err := s.db.Tags() + if err != nil { + return nil, err + } + var fromRules []string + for _, r := range s.engine.Rules() { + fromRules = append(fromRules, r.Tag) + } + return sortedSet(tagged, fromRules), nil +} + +func sortedSet(groups ...[]string) []string { + seen := map[string]bool{} + out := []string{} + for _, g := range groups { + for _, v := range g { + if v == "" || seen[v] { + continue + } + seen[v] = true + out = append(out, v) + } + } + sort.Strings(out) + return out +} + +// --- transactions --------------------------------------------------------- + +// filterFrom reads the scope the transaction list and the report share. The +// report's period is deliberately not part of it: it narrows the report and +// nothing else, so it is read by the report handler alone. +func filterFrom(r *http.Request) store.Filter { + q := r.URL.Query() + return store.Filter{ + AccountSlug: q.Get("account"), + Search: q.Get("search"), + Untagged: q.Get("untagged") == "1", + } +} + +type txnRow struct { + Date string `json:"date"` + Account string `json:"account"` + Amount money `json:"amount"` + Currency string `json:"currency"` + Tag string `json:"tag"` + // Supplied marks a tag column the tool filled in — model.TransferTag — + // rather than one a rule wrote, so it can be shown as a label. + Supplied bool `json:"supplied"` + Description string `json:"description"` + Type string `json:"type"` + Balance string `json:"balance"` +} + +func (s *Server) transactions(r *http.Request) (any, error) { + txns, err := s.db.Transactions(filterFrom(r)) + if err != nil { + return nil, err + } + rows := make([]txnRow, 0, len(txns)) + for _, t := range txns { + balance := "" + if t.BalanceMinor != nil { + balance = model.FormatMinor(*t.BalanceMinor, t.MinorDigits) + } + rows = append(rows, txnRow{ + Date: t.Date, Account: t.AccountSlug, Amount: formatMoney(t.AmountMinor, t.MinorDigits), + Currency: t.Currency, Tag: t.DisplayTag(), Supplied: t.RuleTag == "" && t.IsTransferLeg(), + Description: t.Description, Type: t.Type, Balance: balance, + }) + } + return map[string]any{"rows": rows}, nil +} + +// --- report --------------------------------------------------------------- + +type periodJSON struct { + Label string `json:"label"` + Span string `json:"span"` + Name string `json:"name"` // label and span together, for a title + // Bounded is false for all time, the one window with nothing to be empty + // of: an empty report there means the index is empty, not the period. + Bounded bool `json:"bounded"` +} + +type orderJSON struct { + Name string `json:"name"` + Label string `json:"label"` + Column string `json:"column"` + Ascending bool `json:"ascending"` +} + +type tagRow struct { + Tag string `json:"tag"` + Currency string `json:"currency"` + Out money `json:"out"` + In money `json:"in"` + Net money `json:"net"` + Count int `json:"count"` +} + +type excludedRow struct { + Currency string `json:"currency"` + Out money `json:"out"` + In money `json:"in"` + Net money `json:"net"` + Legs int `json:"legs"` + // Fee is present only when a tolerant definition let one through. It is + // reported, never forgiven: the pair left the report with it inside. + Fee *money `json:"fee,omitempty"` + FeeNet *money `json:"feeNet,omitempty"` + Pairs int `json:"pairs"` +} + +type reportJSON struct { + Periods []periodJSON `json:"periods"` + Period int `json:"period"` + Orders []orderJSON `json:"orders"` + Order string `json:"order"` + Rows []tagRow `json:"rows"` + Totals []tagRow `json:"totals"` + Excluded []excludedRow `json:"excluded"` + IndexEmpty bool `json:"indexEmpty"` +} + +// report answers for one period of the shared scope. The axis is built from +// the whole index, and the period is named by its label rather than its +// position, so an import that grows the axis keeps the page on the window it +// was looking at. +func (s *Server) report(r *http.Request) (any, error) { + q := r.URL.Query() + order := report.OrderOut + if name := q.Get("sort"); name != "" { + o, err := report.ParseOrder(name) + if err != nil { + return nil, badRequest("%v", err) + } + order = o + } + + months, err := s.db.Months() + if err != nil { + return nil, err + } + periods := report.Periods(s.now(), months) + current := report.DefaultIndex(periods) + if label := q.Get("period"); label != "" { + for i, p := range periods { + if p.Label == label { + current = i + break + } + } + } + + f := filterFrom(r) + f.From, f.To = periods[current].From, periods[current].To + txns, err := s.db.Transactions(f) + if err != nil { + return nil, err + } + + out := reportJSON{ + Period: current, Order: order.String(), IndexEmpty: len(months) == 0, + Rows: []tagRow{}, Totals: []tagRow{}, Excluded: []excludedRow{}, + } + for _, p := range periods { + out.Periods = append(out.Periods, periodJSON{ + Label: p.Label, Span: p.Span, Name: p.String(), Bounded: p.From != "", + }) + } + for _, o := range report.Orders() { + out.Orders = append(out.Orders, orderJSON{ + Name: o.String(), Label: o.Label(), Column: o.Column(), Ascending: o.Ascending(), + }) + } + + rows := report.ByTag(txns, order) + for _, t := range rows { + out.Rows = append(out.Rows, tagRow{ + Tag: t.Tag, Currency: t.Currency, Count: t.Count, + Out: formatMoney(t.Out, t.Digits), In: formatMoney(t.In, t.Digits), + Net: formatMoney(t.Net(), t.Digits), + }) + } + for _, c := range report.Totals(rows) { + out.Totals = append(out.Totals, tagRow{ + Tag: "TOTAL", Currency: c.Currency, + Out: formatMoney(c.Out, c.Digits), In: formatMoney(c.In, c.Digits), + Net: formatMoney(c.Net(), c.Digits), + }) + } + // What ByTag held out, or the report silently disagrees with the account + // balances by the amount moved between accounts and any fee taken on the way. + for _, x := range report.Excluded(txns) { + row := excludedRow{ + Currency: x.Currency, Legs: x.Legs, Pairs: x.Pairs, + Out: formatMoney(x.Out, x.Digits), In: formatMoney(x.In, x.Digits), + Net: formatMoney(x.In-x.Out, x.Digits), + } + if x.Fee != 0 { + fee, net := formatMoney(x.Fee, x.Digits), formatMoney(-x.Fee, x.Digits) + row.Fee, row.FeeNet = &fee, &net + } + out.Excluded = append(out.Excluded, row) + } + return out, nil +} + +// --- rules ---------------------------------------------------------------- + +// ruleJSON is config.Rule on the wire. config.Rule carries only TOML tags, and +// the type is kept separate on purpose so a wire format change can never leak +// into rules.toml. +type ruleJSON struct { + Match string `json:"match"` + Type string `json:"type"` + Account string `json:"account"` + Tag string `json:"tag"` + Note string `json:"note"` +} + +func toRuleJSON(r config.Rule) ruleJSON { + return ruleJSON{Match: r.Match, Type: r.Type, Account: r.Account, Tag: r.Tag, Note: r.Note} +} + +func (r ruleJSON) rule() config.Rule { + return config.Rule{Match: r.Match, Type: r.Type, Account: r.Account, Tag: r.Tag, Note: r.Note} +} + +type ruleRow struct { + ruleJSON + // Pos is the file position, 0-based. Anything that reports a rule reports + // this, since it is what rules.toml is edited and deleted by. + Pos int `json:"pos"` + Pattern string `json:"pattern"` + Usage int `json:"usage"` +} + +// ruleList is every rule in file order with how many transactions it wins — +// not how many its glob could match — so a shadowed rule shows as zero. +func (s *Server) ruleList(*http.Request) (any, error) { + txns, err := s.db.Transactions(store.Filter{}) + if err != nil { + return nil, err + } + rs := s.engine.Rules() + usage := s.engine.Usage(txns) + rows := make([]ruleRow, 0, len(rs)) + for i, r := range rs { + rows = append(rows, ruleRow{ + ruleJSON: toRuleJSON(r), Pos: i, Pattern: rulePattern(r), Usage: usage[i], + }) + } + return map[string]any{"rules": rows}, nil +} + +// rulePattern renders whichever patterns a rule sets, labelled so a type rule +// is not mistaken for a description one. +func rulePattern(r config.Rule) string { + var parts []string + if r.Match != "" { + parts = append(parts, r.Match) + } + if r.Type != "" { + parts = append(parts, "type:"+r.Type) + } + return strings.Join(parts, " + ") +} + +type rulePreviewReq struct { + Glob string `json:"glob"` + Account string `json:"account"` + // Edit is the file position of the rule being edited, or nil for a new one. + Edit *int `json:"edit"` + Sort string `json:"sort"` // "name" or "count" +} + +type previewRow struct { + // Marker is "▸" for a match, "−" for a description the edited rule claims + // now and would let go of, and "" for context while no glob is typed. + Marker string `json:"marker"` + Description string `json:"description"` + Count int `json:"count"` +} + +type rulePreviewJSON struct { + Rows []previewRow `json:"rows"` + Matches int `json:"matches"` + Candidates int `json:"candidates"` + Dropped int `json:"dropped"` +} + +// descGroup is one distinct description the builder previews against. A rule +// is written against a description, not against rows, so the preview groups. +type descGroup struct { + Description string + Accounts map[string]bool + Count int + Claimed bool +} + +// rulePreview is the TUI builder's right-hand panel: what the glob would claim +// among everything still waiting for a rule, plus — when editing — what the +// rule claims already, since those are tagged and an untagged-only preview +// would be empty for a rule that works. Matching runs here rather than in the +// browser, so it is the very glob.Match and normalisation the engine uses. +func (s *Server) rulePreview(r *http.Request) (any, error) { + var req rulePreviewReq + if err := decode(r, &req); err != nil { + return nil, err + } + if req.Edit != nil && (*req.Edit < 0 || *req.Edit >= len(s.engine.Rules())) { + return nil, staleRules() + } + + groups, err := s.previewGroups(req.Edit) + if err != nil { + return nil, err + } + sort.Slice(groups, func(i, j int) bool { + a, b := groups[i], groups[j] + // Ties fall back to the name, or the list would reshuffle between + // keystrokes. + if req.Sort == "count" && a.Count != b.Count { + return a.Count > b.Count + } + x, y := model.NormalizeDescription(a.Description), model.NormalizeDescription(b.Description) + if x != y { + return x < y + } + return a.Description < b.Description + }) + + pattern, account := strings.TrimSpace(req.Glob), strings.TrimSpace(req.Account) + out := rulePreviewJSON{Rows: []previewRow{}} + for _, g := range groups { + inAccount := account == "" || g.Accounts[account] + if inAccount { + out.Candidates++ + } + matched := inAccount && + (pattern == "" || glob.Match(pattern, model.NormalizeDescription(g.Description))) + + marker := "" + switch { + case matched && pattern != "": + marker = "▸" + out.Matches++ + case matched: + // No glob yet, so the row is context rather than an answer. + case g.Claimed: + // Giving a description up is the decision an edit makes, so it stays + // on screen instead of vanishing with the other non-matches. + marker = "−" + out.Dropped++ + default: + continue + } + out.Rows = append(out.Rows, previewRow{Marker: marker, Description: g.Description, Count: g.Count}) + } + return out, nil +} + +func (s *Server) previewGroups(edit *int) ([]descGroup, error) { + txns, err := s.db.Transactions(store.Filter{Untagged: true}) + if err != nil { + return nil, err + } + claimedFrom := len(txns) + if edit != nil { + seen := make(map[int64]bool, len(txns)) + for _, t := range txns { + seen[t.ID] = true + } + all, err := s.db.Transactions(store.Filter{}) + if err != nil { + return nil, err + } + for _, t := range all { + if seen[t.ID] || s.engine.MatchIndex(t.AccountSlug, t) != *edit { + continue + } + txns = append(txns, t) + } + } + + byDesc := map[string]*descGroup{} + for i, t := range txns { + key := model.NormalizeDescription(t.Description) + g, ok := byDesc[key] + if !ok { + g = &descGroup{Description: t.Description, Accounts: map[string]bool{}} + byDesc[key] = g + } + g.Accounts[t.AccountSlug] = true + g.Count++ + g.Claimed = g.Claimed || i >= claimedFrom + } + out := make([]descGroup, 0, len(byDesc)) + for _, g := range byDesc { + out = append(out, *g) + } + return out, nil +} + +// ruleForm is the builder's four fields. There is no type field: an edit takes +// the type from the rule on disk, so a pattern the user was never shown is +// never one they removed. +type ruleForm struct { + Match string `json:"match"` + Account string `json:"account"` + Tag string `json:"tag"` + Note string `json:"note"` +} + +func (f ruleForm) rule() config.Rule { + return config.Rule{ + Match: strings.TrimSpace(f.Match), + Account: strings.TrimSpace(f.Account), + Tag: strings.TrimSpace(f.Tag), + Note: strings.TrimSpace(f.Note), + } +} + +func (s *Server) checkRule(r config.Rule) error { + if r.Match == "" && r.Type == "" { + return badRequest("enter a glob first, e.g. *LIDL*") + } + if r.Tag == "" { + return badRequest("enter a tag to apply") + } + if r.Account != "" { + known, err := s.knownAccount(r.Account) + if err != nil { + return err + } + if !known { + return badRequest("no account called %q; leave it blank to apply to every account", r.Account) + } + } + return nil +} + +func (s *Server) createRule(r *http.Request) (any, error) { + var form ruleForm + if err := decode(r, &form); err != nil { + return nil, err + } + rule := form.rule() + if err := s.checkRule(rule); err != nil { + return nil, err + } + if err := config.AppendRule(s.root, rule); err != nil { + return nil, err + } + n, err := s.retagAfterSave() + if err != nil { + return nil, err + } + return status{fmt.Sprintf("saved rule %s → %s, %d transactions retagged", rule.Match, rule.Tag, n)}, nil +} + +type editRuleReq struct { + Rule ruleForm `json:"rule"` + // Expect is the rule as the page showed it. The edit goes ahead only if + // rules.toml still holds exactly that at the position, since the position + // alone could by now name a different rule. + Expect ruleJSON `json:"expect"` +} + +func (s *Server) editRule(r *http.Request) (any, error) { + pos, err := strconv.Atoi(r.PathValue("pos")) + if err != nil { + return nil, badRequest("bad rule position %q", r.PathValue("pos")) + } + var req editRuleReq + if err := decode(r, &req); err != nil { + return nil, err + } + onDisk, err := s.expectRules([]int{pos}, []ruleJSON{req.Expect}) + if err != nil { + return nil, err + } + + rule := req.Rule.rule() + rule.Type = onDisk[pos].Type + if err := s.checkRule(rule); err != nil { + return nil, err + } + if err := config.ReplaceRule(s.root, pos, rule); err != nil { + return nil, err + } + n, err := s.retagAfterSave() + if err != nil { + return nil, err + } + return status{fmt.Sprintf("rule %d is now %s → %s, %d transactions retagged", + pos+1, rulePattern(rule), rule.Tag, n)}, nil +} + +// expectRules checks that rules.toml on disk still holds the expected rule at +// every position, returning what it holds. +func (s *Server) expectRules(positions []int, expect []ruleJSON) ([]config.Rule, error) { + loaded, err := config.LoadRules(s.root) + if err != nil { + return nil, err + } + if len(positions) != len(expect) { + return nil, badRequest("%d positions but %d expected rules", len(positions), len(expect)) + } + for i, pos := range positions { + if pos < 0 || pos >= len(loaded.Rule) || loaded.Rule[pos] != expect[i].rule() { + return nil, staleRules() + } + } + return loaded.Rule, nil +} + +// retagAfterSave makes a rules.toml edit take effect: what runs is re-read +// from disk rather than patched in memory, then every tag is re-derived. +func (s *Server) retagAfterSave() (int, error) { + if err := s.reloadRules(); err != nil { + return 0, fmt.Errorf("rules.toml was written, but re-reading it failed: %w", err) + } + return s.engine.Retag(s.db) +} + +func (s *Server) reloadRules() error { + loaded, err := config.LoadRules(s.root) + if err != nil { + return err + } + s.engine = rules.New(loaded) + s.links = transfers.New(loaded) + return nil +} + +type deleteRulesReq struct { + Positions []int `json:"positions"` + Expect []ruleJSON `json:"expect"` +} + +// deleteRules serves both the single delete and the prune: the page sends the +// positions it confirmed, together with what it showed at each. +func (s *Server) deleteRules(r *http.Request) (any, error) { + var req deleteRulesReq + if err := decode(r, &req); err != nil { + return nil, err + } + if len(req.Positions) == 0 { + return nil, badRequest("no rules to delete") + } + if _, err := s.expectRules(req.Positions, req.Expect); err != nil { + return nil, err + } + n, err := config.DeleteRules(s.root, req.Positions) + if err != nil { + return nil, err + } + retagged, err := s.retagAfterSave() + if err != nil { + return nil, err + } + return status{fmt.Sprintf("deleted %d rule(s), %d transactions retagged", n, retagged)}, nil +} + +// --- transfers ------------------------------------------------------------ + +type transferJSON struct { + FromAccount string `json:"fromAccount"` + FromDesc string `json:"fromDesc"` + ToAccount string `json:"toAccount"` + ToDesc string `json:"toDesc"` + TolerancePct float64 `json:"tolerancePct"` + Note string `json:"note"` +} + +func toTransferJSON(t config.Transfer) transferJSON { + return transferJSON{ + FromAccount: t.FromAccount, FromDesc: t.FromDesc, ToAccount: t.ToAccount, + ToDesc: t.ToDesc, TolerancePct: t.TolerancePct, Note: t.Note, + } +} + +func (t transferJSON) transfer() config.Transfer { + return config.Transfer{ + FromAccount: t.FromAccount, FromDesc: t.FromDesc, ToAccount: t.ToAccount, + ToDesc: t.ToDesc, TolerancePct: t.TolerancePct, Note: t.Note, + } +} + +type transferRow struct { + transferJSON + Pos int `json:"pos"` + Tolerance string `json:"tolerance"` // blank for the exact-amount default + Paired int `json:"paired"` + Orphaned int `json:"orphaned"` +} + +func (s *Server) transferList(*http.Request) (any, error) { + txns, err := s.db.Transactions(store.Filter{}) + if err != nil { + return nil, err + } + defs := s.links.Transfers() + res := s.links.Analyze(txns) + rows := make([]transferRow, 0, len(defs)) + for i, t := range defs { + rows = append(rows, transferRow{ + transferJSON: toTransferJSON(t), Pos: i, Tolerance: formatTolerance(t.TolerancePct), + Paired: res.Paired[i], Orphaned: res.Orphaned[i], + }) + } + return map[string]any{"transfers": rows, "unpaired": len(res.Unmatched)}, nil +} + +// formatTolerance leaves the default blank, so the one or two definitions +// actually pairing on slack are what the column shows. +func formatTolerance(pct float64) string { + if pct == 0 { + return "" + } + return strconv.FormatFloat(pct, 'f', -1, 64) + "%" +} + +// transferForm is the builder's fields as typed. The tolerance stays text so a +// half-typed "1." can still preview; saving is where it has to be a number. +type transferForm struct { + FromAccount string `json:"fromAccount"` + FromDesc string `json:"fromDesc"` + ToAccount string `json:"toAccount"` + ToDesc string `json:"toDesc"` + Tolerance string `json:"tolerance"` + Note string `json:"note"` +} + +func (f transferForm) tolerance() (float64, error) { + v := strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(f.Tolerance), "%")) + if v == "" { + return 0, nil + } + pct, err := strconv.ParseFloat(v, 64) + if err != nil { + return 0, badRequest("tolerance %q is not a number", v) + } + return pct, nil +} + +func (f transferForm) draft() config.Transfer { + pct, _ := f.tolerance() + return config.Transfer{ + FromAccount: strings.TrimSpace(f.FromAccount), + FromDesc: strings.TrimSpace(f.FromDesc), + ToAccount: strings.TrimSpace(f.ToAccount), + ToDesc: strings.TrimSpace(f.ToDesc), + TolerancePct: pct, + Note: strings.TrimSpace(f.Note), + } +} + +type transferPreviewRow struct { + Marker string `json:"marker"` // "▸" a pair, "⚠" a leg with no other side + Date string `json:"date"` + Amount string `json:"amount"` + Movement string `json:"movement"` + Description string `json:"description"` +} + +type transferPreviewJSON struct { + Rows []transferPreviewRow `json:"rows"` + Pairs int `json:"pairs"` + Unpaired int `json:"unpaired"` + Fees string `json:"fees,omitempty"` +} + +// transferPreview pairs the draft *after* the definitions on disk, where +// saving would put it, so it cannot promise legs an earlier one claims first. +func (s *Server) transferPreview(r *http.Request) (any, error) { + var form transferForm + if err := decode(r, &form); err != nil { + return nil, err + } + txns, err := s.db.Transactions(store.Filter{}) + if err != nil { + return nil, err + } + defs := append(append([]config.Transfer(nil), s.links.Transfers()...), form.draft()) + res := transfers.New(&config.Rules{Transfer: defs}).Analyze(txns) + mine := len(defs) - 1 + + out := transferPreviewJSON{Rows: []transferPreviewRow{}, Pairs: res.Paired[mine], Unpaired: res.Orphaned[mine]} + var fees int64 + feeDigits := 0 + for _, p := range res.Pairs { + if p.Def != mine { + continue + } + fees += p.Fee() + feeDigits = p.Out.MinorDigits + // Both amounts whenever they disagree: across currencies that is the + // only place the bank's rate shows, within one it is the fee. + amount := model.FormatMinor(-p.Out.AmountMinor, p.Out.MinorDigits) + if p.In.Currency != p.Out.Currency || p.Fee() != 0 { + amount += " → " + model.FormatMinor(p.In.AmountMinor, p.In.MinorDigits) + } + out.Rows = append(out.Rows, transferPreviewRow{ + Marker: "▸", Date: p.Out.Date, Amount: amount, + Movement: p.Out.AccountSlug + " → " + p.In.AccountSlug, Description: p.Out.Description, + }) + } + for _, l := range res.Unmatched { + if l.Def != mine { + continue + } + amount, movement := l.Txn.AmountMinor, "? → "+l.Txn.AccountSlug + if l.Out { + amount, movement = -amount, l.Txn.AccountSlug+" → ?" + } + out.Rows = append(out.Rows, transferPreviewRow{ + Marker: "⚠", Date: l.Txn.Date, Amount: model.FormatMinor(amount, l.Txn.MinorDigits), + Movement: movement, Description: l.Txn.Description, + }) + } + sort.SliceStable(out.Rows, func(i, j int) bool { return out.Rows[i].Date > out.Rows[j].Date }) + if fees != 0 { + out.Fees = model.FormatMinor(fees, feeDigits) + } + return out, nil +} + +func (s *Server) createTransfer(r *http.Request) (any, error) { + var form transferForm + if err := decode(r, &form); err != nil { + return nil, err + } + t := form.draft() + switch { + case t.FromAccount == "": + return nil, badRequest("name the account the money leaves") + case t.FromDesc == "": + return nil, badRequest("enter a glob for the leaving leg, e.g. *TO REVOLUT*") + case t.ToAccount == "": + return nil, badRequest("name the account the money arrives in") + case t.ToDesc == "": + return nil, badRequest("enter a glob for the arriving leg, e.g. *FROM NLB*") + } + for _, slug := range []string{t.FromAccount, t.ToAccount} { + known, err := s.knownAccount(slug) + if err != nil { + return nil, err + } + if !known { + return nil, badRequest("no account called %q", slug) + } + } + // draft swallowed this so the preview could keep up with typing; saving is + // where a value that never became a number is refused, not written as 0. + if _, err := form.tolerance(); err != nil { + return nil, err + } + if err := config.AppendTransfer(s.root, t); err != nil { + return nil, badRequest("%v", err) + } + paired, unpaired, err := s.relinkAfterSave() + if err != nil { + return nil, err + } + return status{fmt.Sprintf("saved transfer %s → %s, %d matched, %d leg(s) unpaired", + t.FromAccount, t.ToAccount, paired, unpaired)}, nil +} + +func (s *Server) relinkAfterSave() (paired, unpaired int, err error) { + if err := s.reloadRules(); err != nil { + return 0, 0, fmt.Errorf("rules.toml was written, but re-reading it failed: %w", err) + } + return s.links.Link(s.db) +} + +type deleteTransfersReq struct { + Positions []int `json:"positions"` + Expect []transferJSON `json:"expect"` +} + +func (s *Server) deleteTransfers(r *http.Request) (any, error) { + var req deleteTransfersReq + if err := decode(r, &req); err != nil { + return nil, err + } + if len(req.Positions) == 0 { + return nil, badRequest("no transfers to delete") + } + if len(req.Positions) != len(req.Expect) { + return nil, badRequest("%d positions but %d expected transfers", len(req.Positions), len(req.Expect)) + } + loaded, err := config.LoadRules(s.root) + if err != nil { + return nil, err + } + for i, pos := range req.Positions { + if pos < 0 || pos >= len(loaded.Transfer) || loaded.Transfer[pos] != req.Expect[i].transfer() { + return nil, staleRules() + } + } + n, err := config.DeleteTransfers(s.root, req.Positions) + if err != nil { + return nil, err + } + paired, unpaired, err := s.relinkAfterSave() + if err != nil { + return nil, err + } + return status{fmt.Sprintf("deleted %d transfer(s), %d matched, %d leg(s) unpaired", n, paired, unpaired)}, nil +} + +// --- import and retag ----------------------------------------------------- + +type importReq struct { + Force bool `json:"force"` +} + +type importFile struct { + Path string `json:"path"` + Parsed int `json:"parsed"` + New int `json:"new"` + Skipped int `json:"skipped"` + Error string `json:"error,omitempty"` + Warnings []string `json:"warnings,omitempty"` +} + +type importJSON struct { + Status string `json:"status"` + Files []importFile `json:"files"` + Failed int `json:"failed"` +} + +// runImport is `money import`. The account folders and rules.toml are re-read +// first, as a fresh process would: the server outlives edits to both, and an +// import that tagged with yesterday's rules would hand back a stale index. +// Per-file failures are reported and the rest of the run continues. +func (s *Server) runImport(r *http.Request) (any, error) { + var req importReq + if err := decode(r, &req); err != nil { + return nil, err + } + accounts, err := config.LoadAccounts(s.root) + if err != nil { + return nil, err + } + if len(accounts) == 0 { + return nil, badRequest("no accounts found in %s (an account is a folder containing %s)", + s.root, config.AccountFile) + } + if err := s.reloadRules(); err != nil { + return nil, err + } + s.accounts = accounts + + res, err := importer.Run(s.root, s.db, s.accounts, s.engine, s.links, importer.Options{Force: req.Force}) + if err != nil { + return nil, err + } + + _, added, skipped := res.Total() + out := importJSON{Files: []importFile{}} + out.Status = fmt.Sprintf("imported: %d new, %d duplicate", added, skipped) + if res.Unpaired > 0 { + out.Status += fmt.Sprintf(" · %d transfer leg(s) unpaired", res.Unpaired) + } + for _, f := range res.Files { + // A file skipped by checksum has nothing to say. + if f.Err == nil && f.Parsed == 0 && len(f.Warnings) == 0 { + continue + } + file := importFile{Path: f.Path, Parsed: f.Parsed, New: f.New, Skipped: f.Skipped, Warnings: f.Warnings} + if f.Err != nil { + file.Error = f.Err.Error() + out.Failed++ + } + out.Files = append(out.Files, file) + } + return out, nil +} + +// retag is `money retag`: both halves of what rules.toml decides, re-derived +// from the file as it is on disk now. +func (s *Server) retag(*http.Request) (any, error) { + if err := s.reloadRules(); err != nil { + return nil, err + } + n, err := s.engine.Retag(s.db) + if err != nil { + return nil, err + } + paired, unpaired, err := s.links.Link(s.db) + if err != nil { + return nil, err + } + msg := fmt.Sprintf("rules re-applied, %d rows changed, %d transfers matched", n, paired) + if unpaired > 0 { + msg += fmt.Sprintf(", %d leg(s) unpaired", unpaired) + } + return status{msg}, nil +} diff --git a/internal/web/server_test.go b/internal/web/server_test.go new file mode 100644 index 0000000..feef663 --- /dev/null +++ b/internal/web/server_test.go @@ -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()) + } + } +} diff --git a/internal/web/static/app.js b/internal/web/static/app.js new file mode 100644 index 0000000..38bc920 --- /dev/null +++ b/internal/web/static/app.js @@ -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); diff --git a/internal/web/static/index.html b/internal/web/static/index.html new file mode 100644 index 0000000..f2bd568 --- /dev/null +++ b/internal/web/static/index.html @@ -0,0 +1,22 @@ + + + + + +money + + + +
+ money + +
+ + +
+
+
+
+ + + diff --git a/internal/web/static/style.css b/internal/web/static/style.css new file mode 100644 index 0000000..729b61f --- /dev/null +++ b/internal/web/static/style.css @@ -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; } +}