Serve the TUI's screens as a web app

money serve puts the seven screens in a browser: accounts, transactions, the
report with its period axis and sort, the rule builder with its live preview
and edit-in-place, the rules list, the transfer builder with its tolerance
preview, and the transfers list. Import and retag are buttons in the header.
It is one binary: the page is plain HTML, CSS and JavaScript embedded with
go:embed, with no framework and no build step.

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 09:48:56 +02:00
co-authored by Claude Opus 5.5
parent 7d4c3eba17
commit 6faf99719a
8 changed files with 2793 additions and 0 deletions
+49
View File
@@ -6,13 +6,19 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"io"
"net/http"
"os"
"os/signal"
"sort"
"strings"
"syscall"
"text/tabwriter"
"time"
"git.petrovv.com/nikola/money/internal/config"
"git.petrovv.com/nikola/money/internal/importer"
@@ -23,6 +29,7 @@ import (
"git.petrovv.com/nikola/money/internal/store"
"git.petrovv.com/nikola/money/internal/transfers"
"git.petrovv.com/nikola/money/internal/tui"
"git.petrovv.com/nikola/money/internal/web"
)
const usage = `money - statement-driven personal finance tracker
@@ -32,6 +39,7 @@ usage: money [--root DIR] <command> [flags]
commands:
tui browse transactions, build the rules that tag them and the
transfers that pair them across accounts (default)
serve the same screens as a web app (--addr, default 127.0.0.1:8080)
import extract transactions from every statement into the index
retag re-apply rules.toml: retag everything and re-pair transfers
ls list transactions
@@ -103,6 +111,8 @@ func run(args []string) error {
return nil
case "tui":
return cmdTUI(abs, args)
case "serve":
return cmdServe(abs, args)
case "help", "-h", "--help":
fmt.Print(usage)
return nil
@@ -474,3 +484,42 @@ func cmdTUI(root string, _ []string) error {
defer o.db.Close()
return tui.Run(root, o.db, o.accounts, o.engine, o.links)
}
// cmdServe serves the TUI's screens over HTTP until interrupted. There is no
// authentication, so it listens on loopback unless told otherwise: anyone who
// can reach the port can rewrite rules.toml and start an import.
func cmdServe(root string, args []string) error {
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
addr := fs.String("addr", "127.0.0.1:8080", "address to listen on")
if err := fs.Parse(args); err != nil {
return err
}
o, err := open(root)
if err != nil {
return err
}
defer o.db.Close()
srv := &http.Server{
Addr: *addr,
Handler: web.New(root, o.db, o.accounts, o.engine, o.links).Handler(),
ReadHeaderTimeout: 10 * time.Second,
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go func() {
<-ctx.Done()
// Let an import in flight finish writing the index before it closes.
shutdown, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
srv.Shutdown(shutdown)
}()
fmt.Printf("serving %s on http://%s\n", root, *addr)
if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
}