summary history files

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 "", ""
}