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
}