Skip to content

Commit

Permalink
coin2html: Add aggregation style choice to register view
Browse files Browse the repository at this point in the history
  • Loading branch information
mkobetic committed Dec 12, 2024
1 parent 4b17eaf commit 57c112d
Show file tree
Hide file tree
Showing 7 changed files with 592 additions and 106 deletions.
10 changes: 7 additions & 3 deletions cmd/coin2html/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,16 @@ When checked transactions of the account and any sub-accounts are shown.

## Aggregate

When None, the individual transactions are shown.
When not None, the transactions are aggregated by the selected aggregation period (Weekly, Monthly, Quarterly, Yearly).
When aggregated with sub-accounts, the SubAccount Max option controls how many "top" sub-accounts should be shown; the rest of the sub-accounts are combined into the "Other" column. Top here means the sub-accounts with the highest average transaction value across the time range.
When `None`, the individual transactions are shown.
When not `None`, the transactions are aggregated by the selected aggregation period (`Weekly`, `Monthly`, `Quarterly`, `Yearly`).
When aggregated with sub-accounts, the `SubAccount Max` option controls how many "top" sub-accounts should be shown; the rest of the sub-accounts are combined into the "Other" column. Top here means the sub-accounts with the highest average transaction value across the time range.

![Register Aggregated Monthly](https://github.com/mkobetic/coin/assets/871693/ca4897e1-54f3-4d94-93c7-c054b925f566)

### Aggregation Style

When transactions are being aggregated, the aggregation is performed using one of two styles. `Flows` style sums the incoming/outgoing amounts for the period, and is generally useful for Income and Expenses accounts. `Balances` style shows the final account balance at the end of the period, and is generally useful for Assets and Liabilities accounts.

## Show Notes

When Aggregate is set to None, and Show Notes is checked, each transaction is displayed with an additional row containing the transaction notes.
Expand Down
4 changes: 2 additions & 2 deletions cmd/coin2html/js/src/commodity.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as d3 from "d3";
import { dateToString } from "./utils";
import { dateToString, last } from "./utils";

// Commodity, Amount and Price

Expand All @@ -10,7 +10,7 @@ function newConversion(prices: Price[]): Conversion {
if (prices.length == 0)
throw new Error("cannot create conversion from empty price list");
const from = prices[0].date;
const to = prices[prices.length - 1].date;
const to = last(prices)!.date;
const dates = d3.timeWeek.range(from, to);
if (dates.length == 0) return (d: Date) => prices[0].value;
// scale from dates to the number of weeks/price points
Expand Down
42 changes: 35 additions & 7 deletions cmd/coin2html/js/src/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,15 @@ import {
emptyElement,
MainView,
addShowLocationInput,
addAggregationStyleInput,
AggregationStyle,
} from "./views";
import { Account, Posting } from "./account";
import {
dateToString,
groupBy,
groupWithSubAccounts,
last,
trimToDateRange,
} from "./utils";
import { Amount } from "./commodity";
Expand Down Expand Up @@ -46,11 +49,12 @@ export function viewRegister(options?: {
emptyElement(containerSelector);
addIncludeSubAccountsInput(containerSelector);
addAggregateInput(containerSelector);
if (State.View.ShowSubAccounts && State.View.Aggregate != "None")
addSubAccountMaxInput(containerSelector);
if (State.View.Aggregate == "None") {
addIncludeNotesInput(containerSelector);
addShowLocationInput(containerSelector);
} else {
addAggregationStyleInput(containerSelector);
if (State.View.ShowSubAccounts) addSubAccountMaxInput(containerSelector);
}
const groupKey = Aggregation[State.View.Aggregate];
if (groupKey) {
Expand Down Expand Up @@ -97,7 +101,12 @@ function viewRegisterAggregated(
.data((g) => {
const row = [
[dateToString(g.date), "date"],
[g.sum, "amount"],
[
State.View.AggregationStyle == AggregationStyle.Balances
? g.balance
: g.sum,
"amount",
],
];
if (options.aggregatedTotal) row.push([g.total, "amount"]);
return row;
Expand Down Expand Up @@ -126,6 +135,7 @@ function viewRegisterAggregatedWithSubAccounts(
// transpose the groups into row data
const total = new Amount(0, account.commodity);
const data = dates.map((date, i) => {
const balance = new Amount(0, account.commodity);
const sum = new Amount(0, account.commodity);
const postings: Posting[] = [];
const row = groups.map((gs) => {
Expand All @@ -134,10 +144,17 @@ function viewRegisterAggregatedWithSubAccounts(
throw new Error("date mismatch transposing groups");
postings.push(...g.postings);
sum.addIn(g.sum, g.date);
balance.addIn(g.balance, g.date);
return g;
});
total.addIn(sum, date);
row.push({ date: date, postings, sum, total: Amount.clone(total) });
row.push({
date: date,
postings,
sum,
total: Amount.clone(total),
balance,
});
return row;
});
const labels = [
Expand All @@ -157,12 +174,23 @@ function viewRegisterAggregatedWithSubAccounts(
.classed("even", (_, i) => i % 2 == 0)
.selectAll("td")
.data((row) => {
const total = row[row.length - 1];
const columns = row.map((g) => [g.sum, "amount"]);
const total = last(row)!;
const columns = row.map((g) => [
State.View.AggregationStyle == AggregationStyle.Flows
? g.sum
: g.balance,
"amount",
]);
// prepend date
columns.unshift([dateToString(row[0].date), "date"]);
// append total correctly
if (options.aggregatedTotal) columns.push([total.total, "amount"]);
if (options.aggregatedTotal)
columns.push([
State.View.AggregationStyle == AggregationStyle.Flows
? total.total
: total.balance,
"amount",
]);
return columns;
})
.join("td")
Expand Down
27 changes: 18 additions & 9 deletions cmd/coin2html/js/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export type PostingGroup = {
postings: Posting[];
sum: Amount; // sum of posting amounts
total: Amount; // running total across an array of groups
balance: Amount; // balance of last posting in the group (or previous balance if the group is empty)
offset?: number; // used to cache offset value (x) in layered stack chart
width?: number; // used to cache width value (x) in layered stack chart
};
Expand All @@ -38,19 +39,27 @@ export function groupBy(
}
const data: PostingGroup[] = [];
const total = new Amount(0, commodity);
let balance = new Amount(0, commodity);
return groupBy.range(State.StartDate, State.EndDate).map((date) => {
let postings = groups.get(dateToString(date));
const sum = new Amount(0, commodity);
if (!postings) {
if (!postings || postings.length == 0) {
postings = [];
} else {
postings.forEach((p) => sum.addIn(p.quantity, date));
total.addIn(sum, date);
balance = last(postings)!.balance;
}
return { date, postings, sum, total: Amount.clone(total) };
return { date, postings, sum, total: Amount.clone(total), balance };
});
}

// list of groups for an account
export type AccountPostingGroups = {
account?: Account;
groups: PostingGroup[];
};

// Take an array of account posting groups and total them all by
// adding the rest into the first one, return the first
function addIntoFirst(groups: AccountPostingGroups[]): AccountPostingGroups {
Expand All @@ -65,18 +74,13 @@ function addIntoFirst(groups: AccountPostingGroups[]): AccountPostingGroups {
g.postings.push(...g2.postings);
g.sum.addIn(g2.sum, g.date);
g.total.addIn(g2.total, g.date);
g.balance.addIn(g2.balance, g.date);
});
});
total.account = undefined;
return total;
}

// list of groups for an account
export type AccountPostingGroups = {
account?: Account;
groups: PostingGroup[];
};

export function groupWithSubAccounts(
account: Account,
groupKey: d3.TimeInterval,
Expand All @@ -98,7 +102,7 @@ export function groupWithSubAccounts(
const postings = g.groups;
return {
index: i,
avg: postings[postings.length - 1].total.toNumber() / postings.length,
avg: last(postings)!.total.toNumber() / postings.length,
};
});
// sort by average and pick top accounts
Expand All @@ -116,3 +120,8 @@ export function groupWithSubAccounts(
}
return top;
}

export function last<T>(list: T[]): T | undefined {
if (list.length == 0) return undefined;
return list[list.length - 1];
}
24 changes: 24 additions & 0 deletions cmd/coin2html/js/src/views.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ export const Aggregation = {
Yearly: d3.timeYear,
};

export enum AggregationStyle {
Flows = "Flows", // sum of flows for the period
Balances = "Balances", // balance at the end of the period
}

// UI State
export const State = {
// All these must be set after loading of data is finished, see initializeUI()
Expand All @@ -26,6 +31,7 @@ export const State = {
Aggregate: "None" as keyof typeof Aggregation,
// How many largest subaccounts to show when aggregating.
AggregatedSubAccountMax: 5,
AggregationStyle: AggregationStyle.Flows as AggregationStyle,
ShowLocation: false, // Show transaction location info
},
};
Expand Down Expand Up @@ -168,6 +174,24 @@ export function addAggregateInput(
.text((v) => v);
}

export function addAggregationStyleInput(containerSelector: string) {
const container = d3.select(containerSelector);
const aggregate = container.append("select").attr("id", "aggregationStyle");
aggregate.on("change", (e, d) => {
const select = e.currentTarget as HTMLSelectElement;
const selected = select.options[select.selectedIndex].value;
State.View.AggregationStyle = selected as AggregationStyle;
updateView();
});
aggregate
.selectAll("option")
.data(Object.keys(AggregationStyle))
.join("option")
.property("selected", (v) => v == State.View.AggregationStyle)
.property("value", (v) => v)
.text((v) => v);
}

// UI Node Selectors

export const RootAccountSelect = "#sidebar select#root";
Expand Down
3 changes: 2 additions & 1 deletion cmd/gen2coin/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ func init() {
flag.Var(&end, "e", "end ledger on or before this date (default: today)")
flag.BoolVar(&byYear, "y", false, "split ledger into multiple files by year")
flag.BoolVar(&byMonth, "m", false, "split ledger into multiple files by month")
flag.BoolVar(&asJson, "j", false, "dump transactions only as JSON (single file)")
flag.BoolVar(&asJson, "j", false, "dump transactions only as JSON to Stdout")

flag.Usage = func() {
w := flag.CommandLine.Output()
Expand Down Expand Up @@ -71,6 +71,7 @@ func main() {
if dir == "" { // just dump everything into stdout
if asJson {
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", "\t")
encoder.Encode(transactions)
} else {
for _, t := range transactions {
Expand Down
Loading

0 comments on commit 57c112d

Please sign in to comment.