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; } +}