Skip to content

Commit

Permalink
coin2html: add details popup
Browse files Browse the repository at this point in the history
  • Loading branch information
mkobetic committed Dec 31, 2024
1 parent 649b137 commit 3e23c6c
Show file tree
Hide file tree
Showing 11 changed files with 481 additions and 210 deletions.
3 changes: 1 addition & 2 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,8 @@
### coin2html

- allow dropping subaccounts from aggregations (in both chart and register)
- show details of selected posting
- show details of selected posting group
- filter subaccounts, payee, tag...
- brush to select date range
- replace dateToString with d3.format
- try d3 binning for groupBy utils
- try d3 layouts
Expand Down
17 changes: 6 additions & 11 deletions cmd/coin2html/js/spec/commodity.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,9 @@ import {
newConversion,
Price,
} from "../src/commodity";
import { setupCommodities } from "./setup";

for (const [id, decimals] of Object.entries({
USD: 2,
CAD: 2,
EUR: 2,
CZK: 2,
}))
if (!Commodities[id]) Commodities[id] = new Commodity(id, id, decimals, "");
setupCommodities();

describe("amount", () => {
const CAD = commodity`CAD`;
Expand Down Expand Up @@ -91,10 +86,10 @@ describe("conversions", () => {
});

describe("amount conversions", () => {
const CAD = new Commodity("CAD", "CAD", 2, "");
const USD = new Commodity("USD", "USD", 2, "");
const EUR = new Commodity("EUR", "EUR", 2, "");
const CZK = new Commodity("CZK", "CZK", 2, "");
const CAD = Commodities.CAD;
const USD = Commodities.USD;
const EUR = Commodities.EUR;
const CZK = Commodities.CZK;
const day = new Date("2000-01-01");
for (const [com, val, com2] of [
[CAD, 75, USD],
Expand Down
11 changes: 11 additions & 0 deletions cmd/coin2html/js/spec/setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Commodities, Commodity } from "../src/commodity";

export function setupCommodities() {
for (const [id, decimals] of Object.entries({
USD: 2,
CAD: 2,
EUR: 2,
CZK: 2,
}))
if (!Commodities[id]) Commodities[id] = new Commodity(id, id, decimals, "");
}
23 changes: 23 additions & 0 deletions cmd/coin2html/js/spec/utils.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Account, Posting, Transaction } from "../src/account";
import { Amount, amount, commodity } from "../src/commodity";
import { topN } from "../src/utils";
import { setupCommodities } from "./setup";

setupCommodities();

describe("topN", () => {
const CAD = commodity`CAD`;
const t = new Transaction(new Date(), "test");
const a = new Account("test", "test", CAD);
test.each([
[`2 CAD, 5 CAD, 3 CAD, 1 CAD, 4 CAD`, 2, `5.00 CAD, 4.00 CAD`],
[`2 CAD, -5 CAD, -1 CAD, 4 CAD`, 3, `-5.00 CAD, 4.00 CAD, 2.00 CAD`],
])(`%#: %s top %i`, (input, n, expected) => {
const postings = input.split(", ").map((s) => {
const amt = Amount.parse(s);
return new Posting(t, a, amt, amt);
});
const top = topN(postings, n, CAD);
expect(top.map((p) => p.quantity.toString())).toEqual(expected.split(", "));
});
});
18 changes: 9 additions & 9 deletions cmd/coin2html/js/src/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ export class Account {
readonly name: string,
readonly fullName: string,
readonly commodity: Commodity,
readonly parent: Account,
readonly location: string,
readonly closed?: Date
readonly parent?: Account,
readonly closed?: Date,
readonly location?: string
) {
if (parent) {
parent.children.push(this);
Expand Down Expand Up @@ -116,9 +116,9 @@ export class Transaction {
constructor(
readonly posted: Date,
readonly description: string,
readonly location: string,
readonly notes?: string[],
readonly code?: string
readonly code?: string,
readonly location?: string
) {}
toString(): string {
return dateToString(this.posted) + " " + this.description;
Expand Down Expand Up @@ -180,8 +180,8 @@ export function loadAccounts(source: string) {
impAccount.fullName,
Commodity.find(impAccount.commodity),
parent,
impAccount.location,
impAccount.closed ? new Date(impAccount.closed) : undefined
impAccount.closed ? new Date(impAccount.closed) : undefined,
impAccount.location
);
Accounts[account.fullName] = account;
if (!parent) {
Expand All @@ -199,9 +199,9 @@ export function loadTransactions(source: string) {
const transaction = new Transaction(
posted,
impTransaction.description,
impTransaction.location,
impTransaction.notes,
impTransaction.code
impTransaction.code,
impTransaction.location
);
for (const impPosting of impTransaction.postings) {
const account = Accounts[impPosting.account];
Expand Down
4 changes: 3 additions & 1 deletion cmd/coin2html/js/src/chart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
MainView,
AggregationStyle,
addAggregationStyleInput,
showDetails,
} from "./views";
import {
groupByWithSubAccounts,
Expand Down Expand Up @@ -67,6 +68,7 @@ export function viewChartTotals(options?: {
const group = gs.groups[i];
group.offset = offset;
group.width = widthFromGroup(group);
group.account = gs.account;
offset += group.width;
});
max = max < offset ? offset : max;
Expand Down Expand Up @@ -115,7 +117,7 @@ export function viewChartTotals(options?: {
.attr("x", (d) => x(d.offset ?? 0))
.attr("width", (d) => x(d.width ?? 0))
.attr("height", rowHeight - 1)
.on("click", (e, d) => console.log(e, d));
.on("click", (e, d) => showDetails(d, !d.account));

// bar text
layer
Expand Down
19 changes: 11 additions & 8 deletions cmd/coin2html/js/src/commodity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export class Commodity {
readonly id: string,
readonly name: string,
readonly decimals: number,
readonly location: string
readonly location?: string
) {}

static find(id: string): Commodity {
Expand Down Expand Up @@ -145,7 +145,8 @@ export class Commodity {

// convert amount to this commodity using price on given date
convert(amount: Amount, date: Date): Amount {
if (amount.commodity == this || amount.isZero) return new Amount(0, this);
if (amount.commodity == this) return amount;
if (amount.isZero) return new Amount(0, this);
const conversion = amount.commodity.findConversion(this);
if (!conversion)
throw new Error(
Expand Down Expand Up @@ -224,11 +225,13 @@ export class Amount {
// accounting rounding should round 0.5 up
return new Amount(Math.round(float), price.value.commodity);
}
cmp(amount: Amount) {
const decimalDiff = this.commodity.decimals - amount.commodity.decimals;
return decimalDiff < 0
? this.value * 10 ** -decimalDiff - amount.value
: this.value - amount.value * 10 ** decimalDiff;
cmp(amount: Amount, absolute = false) {
if (this.commodity != amount.commodity) {
throw new Error("comparing different commodities");
}
return absolute
? Math.abs(this.value) - Math.abs(amount.value)
: this.value - amount.value;
}
reciprocal(decimals: number): number {
const reciprocal = 10 ** this.commodity.decimals / this.value;
Expand All @@ -254,7 +257,7 @@ export class Price {
readonly commodity: Commodity,
readonly date: Date,
readonly value: Amount,
readonly location: string
readonly location?: string
) {}
static parse(input: string): Price {
const parts = input.split(":");
Expand Down
53 changes: 40 additions & 13 deletions cmd/coin2html/js/src/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ function viewRegisterAggregatedWithSubAccounts(
.join("td")
.classed("amount", ([g, v, c]) => c == "amount")
.text(([g, v, c]) => v(g))
.on("click", (e, [g, v, c]) => showDetails(g));
.on("click", (e, [g, v, c]) => showDetails(g, true));
}

function viewRegisterFull(
Expand All @@ -199,6 +199,34 @@ function viewRegisterFull(
negated: boolean;
}
) {
const data = trimToDateRange(
account.postings,
State.StartDate,
State.EndDate
);
renderPostings(account, data, containerSelector, {
...options,
showLocation: State.View.ShowLocation,
showNotes: State.View.ShowNotes,
});
}

export function renderPostings(
account: Account,
data: Posting[],
containerSelector: string,
optionOverrides: {
negated: boolean;
showLocation?: boolean;
showNotes?: boolean;
}
) {
const options = {
negated: false,
showLocation: false,
showNotes: false,
};
Object.assign(options, optionOverrides);
const labels = [
"Date",
"Description",
Expand All @@ -207,14 +235,10 @@ function viewRegisterFull(
"Balance",
"Cum.Total",
];
if (State.View.ShowLocation) labels.push("Location");
if (options.showLocation) labels.push("Location");
const table = addTableWithHeader(containerSelector, labels);
const total = new Amount(0, account.commodity);
const data = trimToDateRange(
account.postings,
State.StartDate,
State.EndDate
);

const rows = table.append("tbody").selectAll("tr").data(data).enter();
rows
.append("tr")
Expand All @@ -231,15 +255,15 @@ function viewRegisterFull(
[p.balance, "amount"],
[Amount.clone(total), "amount"],
];
if (State.View.ShowLocation)
values.push([p.transaction.location, "text"]);
if (options.showLocation)
values.push([p.transaction.location ?? "", "text"]);
return values;
})
.join("td")
.classed("amount", ([v, c]) => c == "amount")
.attr("rowspan", (_, i) => (i == 0 && State.View.ShowNotes ? 2 : null))
.attr("rowspan", (_, i) => (i == 0 && options.showNotes ? 2 : null))
.text(([v, c]) => v.toString());
if (State.View.ShowNotes) {
if (options.showNotes) {
rows
.append("tr")
.classed("even", (_, i) => i % 2 == 0)
Expand All @@ -266,6 +290,7 @@ function viewRegisterFullWithSubAccounts(
) {
const data = account.withAllChildPostings(State.StartDate, State.EndDate);
renderPostingsWithSubAccounts(account, data, containerSelector, {
...options,
showLocation: State.View.ShowLocation,
showNotes: State.View.ShowNotes,
});
Expand All @@ -275,7 +300,8 @@ export function renderPostingsWithSubAccounts(
account: Account,
data: Posting[],
containerSelector: string,
optionOverrides?: {
optionOverrides: {
negated: boolean;
showLocation?: boolean;
showNotes?: boolean;
}
Expand Down Expand Up @@ -312,7 +338,8 @@ export function renderPostingsWithSubAccounts(
[p.quantity, "amount"],
[Amount.clone(total), "amount"],
];
if (options.showLocation) values.push([p.transaction.location, "text"]);
if (options.showLocation)
values.push([p.transaction.location ?? "", "text"]);
return values;
})
.join("td")
Expand Down
9 changes: 5 additions & 4 deletions cmd/coin2html/js/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export type PostingGroup = {
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
account?: Account; // used to cache account for the group
};

export function balanceOrSum(g: PostingGroup) {
Expand Down Expand Up @@ -66,10 +67,10 @@ export function topN(
commodity: Commodity
): Posting[] {
const top = [...postings];
top.sort(
(a, b) =>
commodity.convert(a.quantity, a.transaction.posted).toNumber() -
commodity.convert(b.quantity, b.transaction.posted).toNumber()
top.sort((a, b) =>
commodity
.convert(b.quantity, b.transaction.posted)
.cmp(commodity.convert(a.quantity, a.transaction.posted), true)
);
return top.slice(0, n);
}
Expand Down
22 changes: 14 additions & 8 deletions cmd/coin2html/js/src/views.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { select } from "d3-selection";
import { timeMonth, timeWeek, timeYear } from "d3-time";
import { renderPostingsWithSubAccounts, viewRegister } from "./register";
import {
renderPostings,
renderPostingsWithSubAccounts,
viewRegister,
} from "./register";
import { viewChartTotals } from "./chart";
import { Account } from "./account";
import { PostingGroup, shortenAccountName, topN } from "./utils";
Expand Down Expand Up @@ -285,20 +289,22 @@ export function updateAccounts() {
updateAccount();
}

export function showDetails(g: PostingGroup) {
export function showDetails(g: PostingGroup, withSubaccounts = false) {
emptyElement(Details);
const details = select(Details);
details
.insert("a")
.text("X")
.on("click", () => details.attr("hidden", true));
const account = State.SelectedAccount;
renderPostingsWithSubAccounts(
account,
topN(g.postings, 20, account.commodity),
Details,
{ showLocation: true }
);
const data = topN(g.postings, 20, account.commodity);
const options = {
negated: false,
showLocation: true,
};
if (withSubaccounts)
renderPostingsWithSubAccounts(account, data, Details, options);
else renderPostings(account, data, Details, options);

details.attr("hidden", null);
}
Loading

0 comments on commit 3e23c6c

Please sign in to comment.