main.go
// Command ledger is a local-first personal-finance dashboard built on gophics,
// and the driver app for the built-in chart package: it renders spending,
// balance, and weekly-activity charts from a sample dataset (a real Ledger would
// import a local CSV/OFX file). One codebase runs on desktop, web, and mobile.
package main
import (
"log"
"golang.org/x/image/font/gofont/gobold"
"golang.org/x/image/font/gofont/goregular"
"github.com/doug/gophics/app"
"github.com/doug/gophics/geom"
)
func main() {
err := app.Run(Ledger{}, app.Config{
Title: "Ledger",
Size: geom.Size{W: 900, H: 820},
Background: BG,
Font: goregular.TTF,
FontFamilies: map[string][]byte{"bold": gobold.TTF},
})
if err != nil {
log.Fatal(err)
}
}
data.go
package main
import (
"math/rand"
"strconv"
"time"
"github.com/doug/gophics/chart"
)
// dataset is the sample finance data the dashboard visualizes. It is generated
// deterministically (fixed seed) so screenshots and tests are stable; a real
// Ledger would import transactions from a local CSV/OFX file.
type dataset struct {
byCategory []chart.Datum // spend per category (this month)
balance []chart.Datum // daily account balance
thisWeek []chart.Datum // spend per weekday
budget float64 // monthly budget target
latest float64 // most recent balance
spent float64 // total spent this month
start, end time.Time // balance-series date range
}
func sampleData() dataset {
r := rand.New(rand.NewSource(42))
cats := []struct {
name string
amt float64
}{
{"Rent", 1850}, {"Food", 620}, {"Transit", 180},
{"Fun", 240}, {"Utilities", 165}, {"Health", 95}, {"Shopping", 310},
}
var byCat []chart.Datum
var spent float64
for i, c := range cats {
byCat = append(byCat, chart.Datum{X: float64(i), Y: c.amt, Label: c.name})
spent += c.amt
}
base := time.Date(2026, time.August, 1, 0, 0, 0, 0, time.UTC)
bal := 4200.0
var balance []chart.Datum
for d := range 30 {
day := base.AddDate(0, 0, d)
balance = append(balance, chart.Datum{X: chart.Seconds(day), Y: bal, Label: day.Format("Jan 2")})
bal += r.Float64()*300 - 130 // gentle drift with a downward bias
}
var week []chart.Datum
for i, day := range []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"} {
week = append(week, chart.Datum{X: float64(i), Y: 20 + r.Float64()*130, Label: day})
}
return dataset{
byCategory: byCat,
balance: balance,
thisWeek: week,
budget: 3800,
latest: bal,
spent: spent,
start: base,
end: base.AddDate(0, 0, 29),
}
}
// money formats a dollar amount with thousands separators, e.g. "$4,120".
func money(v float64) string {
neg := v < 0
if neg {
v = -v
}
s := strconv.FormatInt(int64(v+0.5), 10)
var out []byte
for i, c := range []byte(s) {
if i > 0 && (len(s)-i)%3 == 0 {
out = append(out, ',')
}
out = append(out, c)
}
sign := ""
if neg {
sign = "-"
}
return sign + "$" + string(out)
}
ledger.go
package main
import (
"github.com/doug/gophics/chart"
"github.com/doug/gophics/layout"
"github.com/doug/gophics/paint"
"github.com/doug/gophics/theme"
"github.com/doug/gophics/widget"
)
// BG is the window background used at Start, before a widget context exists (the
// app passes it as Config.Background). Inside the tree every color comes from
// theme.Of(ctx), so the dashboard also follows the platform light/dark scheme.
var BG = theme.Light().Bg
// Ledger is the dashboard root: a stateless widget that lays out a few chart
// cards over the sample dataset.
type Ledger struct{}
func (Ledger) Build(ctx widget.Ctx) widget.Widget {
// Resolve the theme from the platform color scheme and provide it to the tree
// so every card and chart below reads colors from it and follows light/dark.
th := theme.Auto(ctx)
d := sampleData()
// Categorical series colors come from the theme's chart palette; the budget
// threshold uses the semantic Warning token. Chart chrome (labels, axes,
// gridlines) is themed via the chart's Chrome overrides so it tracks light/dark.
balAccent := th.ChartAt(0)
balance := chart.Chart{
Marks: []chart.Mark{
chart.AreaMark{Data: d.balance, Color: balAccent, Alpha: 0.13},
chart.RuleMark{Value: d.budget, Horizontal: true, Dash: 6, Color: th.Warning},
chart.LineMark{Data: d.balance, Width: 2.5, Color: balAccent},
},
X: chart.NewTime(d.start, d.end),
LabelColor: th.Text, AxisColor: th.Muted, GridColor: th.Border,
Animate: true,
}
// One categorical accent per spending slice, wrapping the 6-color palette.
sliceColors := make([]paint.Color, len(d.byCategory))
for i := range d.byCategory {
sliceColors[i] = th.ChartAt(i)
}
donut := chart.Chart{
Marks: []chart.Mark{chart.SectorMark{Inner: 0.58, Data: d.byCategory, Colors: sliceColors}},
XAxis: chart.Axis{Hide: true},
YAxis: chart.Axis{Hide: true},
LabelColor: th.Text,
Legend: true,
Animate: true,
}
week := chart.Chart{
Marks: []chart.Mark{chart.BarMark{Data: d.thisWeek, Color: th.ChartAt(1)}},
LabelColor: th.Text, AxisColor: th.Muted, GridColor: th.Border,
Animate: true,
}
row := widget.Row(
widget.Flexible{Flex: 3, Child: card(th, "Where it goes", "", 300, donut)},
widget.Sized{W: 16},
widget.Flexible{Flex: 2, Child: card(th, "This week", "", 300, week)},
)
row.CrossAlign = layout.CrossStretch
page := column(layout.CrossStretch,
header(th, "Ledger", "August 2026"),
widget.Sized{H: 18},
card(th, "Balance", money(d.latest), 260, balance),
widget.Sized{H: 16},
row,
)
return widget.Provide[theme.Theme]{
Value: th,
Child: widget.Fill{Color: th.Bg, Child: widget.Padding{All: 24, Child: page}},
}
}
// header is the page title + subtitle.
func header(th theme.Theme, title, sub string) widget.Widget {
return column(layout.CrossStart,
widget.Text{Value: title, Font: theme.FontBold, Size: th.Type.Display, Color: th.Text},
widget.Sized{H: 3},
widget.Text{Value: sub, Size: th.Type.Body, Color: th.Muted},
)
}
// card is a titled surface panel of fixed height wrapping a chart (or any body).
// A non-empty value is shown large under the title.
func card(th theme.Theme, title, value string, h float32, body widget.Widget) widget.Widget {
head := []widget.Widget{widget.Text{Value: title, Font: theme.FontBold, Size: th.Type.Heading, Color: th.Text}}
if value != "" {
head = append(head,
widget.Sized{H: 4},
widget.Text{Value: value, Font: theme.FontBold, Size: th.Type.Display, Color: th.Text})
}
head = append(head, widget.Sized{H: 12}, widget.Expand(body))
return widget.Sized{H: h, Child: widget.Decorated{
Color: th.Surface, Radius: th.Radius + 6, BorderColor: th.Border, BorderWidth: 1,
Child: widget.Padding{All: 20, Child: column(layout.CrossStretch, head...)},
}}
}
func column(cross layout.CrossAlign, kids ...widget.Widget) widget.Widget {
c := widget.Column(kids...)
c.CrossAlign = cross
return c
}