Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4ac034ea0 |
@@ -21,7 +21,6 @@ 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
|
||||
@@ -225,28 +224,6 @@ 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
|
||||
|
||||
@@ -85,8 +85,6 @@ only if you hand the binary to someone else.
|
||||
|
||||
```
|
||||
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
|
||||
@@ -435,47 +433,6 @@ 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
|
||||
|
||||
@@ -6,19 +6,13 @@
|
||||
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"
|
||||
@@ -29,7 +23,6 @@ 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
|
||||
@@ -39,7 +32,6 @@ usage: money [--root DIR] <command> [flags]
|
||||
commands:
|
||||
tui browse transactions, build the rules that tag them and the
|
||||
transfers that pair them across accounts (default)
|
||||
serve the same screens as a web app (--addr, default 127.0.0.1:8080)
|
||||
import extract transactions from every statement into the index
|
||||
retag re-apply rules.toml: retag everything and re-pair transfers
|
||||
ls list transactions
|
||||
@@ -111,8 +103,6 @@ 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
|
||||
@@ -485,42 +475,3 @@ func cmdTUI(root string, _ []string) error {
|
||||
defer o.db.Close()
|
||||
return tui.Run(root, o.db, o.accounts, o.engine, o.links)
|
||||
}
|
||||
|
||||
// cmdServe serves the TUI's screens over HTTP until interrupted. There is no
|
||||
// authentication, so it listens on loopback unless told otherwise: anyone who
|
||||
// can reach the port can rewrite rules.toml and start an import.
|
||||
func cmdServe(root string, args []string) error {
|
||||
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
|
||||
addr := fs.String("addr", "127.0.0.1:8080", "address to listen on")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
o, err := open(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer o.db.Close()
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: *addr,
|
||||
Handler: web.New(root, o.db, o.accounts, o.engine, o.links).Handler(),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
// Let an import in flight finish writing the index before it closes.
|
||||
shutdown, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
srv.Shutdown(shutdown)
|
||||
}()
|
||||
|
||||
fmt.Printf("serving %s on http://%s\n", root, *addr)
|
||||
if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,388 +0,0 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,899 +0,0 @@
|
||||
// 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);
|
||||
@@ -1,22 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>money</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="top">
|
||||
<a class="brand" href="#accounts">money</a>
|
||||
<nav id="nav" aria-label="Screens"></nav>
|
||||
<div class="actions">
|
||||
<button type="button" id="retag" title="Re-apply rules.toml: retag everything and re-pair transfers">Retag</button>
|
||||
<button type="button" id="import" class="primary" title="Import new statements from every account folder">Import</button>
|
||||
</div>
|
||||
</header>
|
||||
<div id="banners"></div>
|
||||
<main id="main"></main>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,189 +0,0 @@
|
||||
: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; }
|
||||
}
|
||||
Reference in New Issue
Block a user