internal/render/render.go
package render
import (
"bytes"
"encoding/json"
"fmt"
"gt/internal/report"
"gt/internal/store"
"io"
"regexp"
"sort"
"strings"
"github.com/olekukonko/tablewriter"
"github.com/olekukonko/tablewriter/renderer"
"github.com/olekukonko/tablewriter/tw"
)
type Renderer interface {
Render(w io.Writer, data any, opts ...RendererOptsFunc) error
}
type Format string
const (
FormatJSON Format = "json"
FormatTable Format = "table"
)
func New(format string, opts ...RendererOptsFunc) (Renderer, error) {
switch Format(format) {
case FormatJSON:
return &JSONRenderer{}, nil
case FormatTable:
return &TableRenderer{}, nil
default:
return nil, fmt.Errorf("unsupported format: %s", format)
}
}
type JSONRenderer struct {
opts *RendererOpts
}
func (j *JSONRenderer) Render(w io.Writer, data any, _ ...RendererOptsFunc) error {
encoder := json.NewEncoder(w)
encoder.SetIndent("", " ")
return encoder.Encode(data)
}
type TableRenderer struct{}
type RendererOpts struct {
includeTotals bool
// use accounts short name (i.e. pizza) when rendering instead of full name
// (i.e expenses:dining:pizza)
accountShortName bool
// sortBy determines how rows are sorted when being rendered within the
// table.
sortBy string
}
func defaultRendererOpts() *RendererOpts {
return &RendererOpts{
includeTotals: false,
accountShortName: false,
sortBy: "name",
}
}
type RendererOptsFunc func(*RendererOpts)
func WithAccountShortName(b bool) RendererOptsFunc {
return func(o *RendererOpts) {
o.accountShortName = b
}
}
func WithSortBy(s string) RendererOptsFunc {
return func(o *RendererOpts) {
o.sortBy = s
}
}
func WithIncludeTotals(b bool) RendererOptsFunc {
return func(o *RendererOpts) {
o.includeTotals = b
}
}
func renderReportProfitLoss(table *tablewriter.Table, opts RendererOpts, r report.IncomeExpenseReport) {
fn := func(cfg *tablewriter.Config) {
cfg.Header = tw.CellConfig{Alignment: tw.CellAlignment{Global: tw.AlignCenter}}
cfg.Row = tw.CellConfig{
Merging: tw.CellMerging{Mode: tw.MergeHierarchical},
Alignment: tw.CellAlignment{Global: tw.AlignLeft},
}
}
table.Configure(fn)
o := []tablewriter.Option{
tablewriter.WithRenderer(renderer.NewBlueprint(tw.Rendition{Borders: tw.BorderNone})),
}
table.Options(o...)
createSubTable := func(items []*report.AccountItem) string {
var buf bytes.Buffer
table := tablewriter.NewTable(&buf,
tablewriter.WithRenderer(renderer.NewBlueprint(tw.Rendition{
Settings: tw.Settings{
Lines: tw.Lines{ShowFooterLine: tw.On},
},
})),
tablewriter.WithConfig(tablewriter.Config{
Header: tw.CellConfig{
Alignment: tw.CellAlignment{
Global: tw.AlignLeft,
},
},
Row: tw.CellConfig{
Formatting: tw.CellFormatting{
AutoWrap: int(tw.Off),
},
ColMaxWidths: tw.CellWidth{Global: 80},
},
}),
)
num := int64(0)
denom := int64(0)
table.Header([]string{"Account", "Value"})
switch opts.sortBy {
case "value":
sort.Slice(items, func(i, j int) bool {
return items[i].ValueNum > items[j].ValueNum
})
default:
// NOTE(rene): default to sorting by name
sort.Slice(items, func(i, j int) bool {
return strings.Compare(
strings.ToLower(items[i].Account.FullName),
strings.ToLower(items[j].Account.FullName),
) < 0
})
}
for _, i := range items {
value := ""
debit, credit := formatAmount(i.ValueNum, i.ValueDenom)
value = credit
if debit != "" {
value = debit
}
re := regexp.MustCompile(`^(Income|Expenses)\:`)
accountName := re.ReplaceAllString(i.Account.FullName, "")
num = num + i.ValueNum
denom = i.ValueDenom
table.Append([]string{
accountName,
value,
})
}
d, c := formatAmount(num, denom)
v := c
if d != "" {
v = d
}
table.Footer([]string{"Total", v})
table.Render()
return buf.String()
}
table.Append([]string{createSubTable(r.Income)})
table.Append([]string{createSubTable(r.Expense)})
}
func renderAccounts(table *tablewriter.Table, opts RendererOpts, accounts []*store.Account) {
table.Header([]string{"Name", "Account Type", "Description"})
for _, account := range accounts {
name := account.FullName
if opts.accountShortName {
name = account.Name
}
description := ""
if account.Description != nil {
description = *account.Description
}
table.Append([]string{
name,
account.AccountType,
description,
})
}
}
func renderTransactions(table *tablewriter.Table, opts RendererOpts, transactions []*store.Transaction) {
table.Header([]string{"Date", "Description", "Account", "Debit", "Credit"})
type AccountTotal struct {
Name string
TotalNum int64
TotalDenom int64
}
accountTotals := make(map[string]*AccountTotal)
for _, transaction := range transactions {
if len(transaction.Splits) == 0 {
continue
}
description := ""
if transaction.Description != nil {
description = *transaction.Description
}
table.Append([]string{
transaction.PostDate.Local().Format("2006-01-02"),
description,
"",
"",
"",
})
for _, split := range transaction.Splits {
debit, credit := formatAmount(split.ValueNum, split.ValueDenom)
accountName := split.Account.FullName
if opts.accountShortName {
accountName = split.Account.Name
}
table.Append([]string{
"",
"",
accountName,
debit,
credit,
})
accountGUID := split.AccountGUID
if _, exists := accountTotals[accountGUID]; !exists {
accountTotals[accountGUID] = &AccountTotal{
Name: accountName,
TotalNum: 0,
TotalDenom: split.ValueDenom,
}
}
accountTotals[accountGUID].TotalNum += split.ValueNum
}
table.Append([]string{"", "", "", "", ""})
}
if len(accountTotals) > 0 && opts.includeTotals {
table.Append([]string{"", "TOTALS", "", ""})
type sortableTotal struct {
name string
total *AccountTotal
}
sortedAccounts := make([]sortableTotal, 0, len(accountTotals))
for _, total := range accountTotals {
sortedAccounts = append(sortedAccounts, sortableTotal{name: total.Name, total: total})
}
for i := 0; i < len(sortedAccounts); i++ {
for j := i + 1; j < len(sortedAccounts); j++ {
if sortedAccounts[i].name > sortedAccounts[j].name {
sortedAccounts[i], sortedAccounts[j] = sortedAccounts[j], sortedAccounts[i]
}
}
}
for _, sortedAccount := range sortedAccounts {
debit, credit := formatAmount(sortedAccount.total.TotalNum, sortedAccount.total.TotalDenom)
table.Append([]string{
"",
"",
sortedAccount.name,
debit,
credit,
})
}
}
}
func (t *TableRenderer) Render(w io.Writer, data any, opts ...RendererOptsFunc) error {
o := defaultRendererOpts()
for _, fn := range opts {
fn(o)
}
cfg := tablewriter.Config{
Header: tw.CellConfig{
Alignment: tw.CellAlignment{
Global: tw.AlignLeft,
},
},
Row: tw.CellConfig{
Formatting: tw.CellFormatting{
AutoWrap: int(tw.Off),
},
},
}
table := tablewriter.NewTable(w, tablewriter.WithConfig(cfg))
switch v := data.(type) {
case []*store.Account:
renderAccounts(table, *o, v)
case *store.Account:
renderAccounts(table, *o, []*store.Account{v})
case *store.Transaction:
renderTransactions(table, *o, []*store.Transaction{v})
case []*store.Transaction:
renderTransactions(table, *o, v)
case report.IncomeExpenseReport:
renderReportProfitLoss(table, *o, v)
default:
return fmt.Errorf("unsupported model type: %T", data)
}
return table.Render()
}
func formatAmount(valueNum, valueDenom int64) (debit, credit string) {
if valueDenom == 0 {
return "", ""
}
amount := float64(valueNum) / float64(valueDenom)
if amount < 0 {
return "", fmt.Sprintf("%.2f", amount)
}
if amount > 0 {
return fmt.Sprintf("%.2f", amount), ""
}
return "", ""
}