Skip to content

Commit

Permalink
coin2html: refactoring the source files
Browse files Browse the repository at this point in the history
  • Loading branch information
mkobetic committed Dec 8, 2024
1 parent 0509610 commit 9dd7d6e
Show file tree
Hide file tree
Showing 12 changed files with 142,857 additions and 2,309 deletions.
5 changes: 3 additions & 2 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,19 +24,20 @@

### coin2html

- chart doesn't respect SelectedAccount
- chart and register aggregation should show periodic balances not aggregated inflows for Assets/Liabilities
- show location info
- replace dateToString with d3.format
- thousands separator
- tooltips for columns, inputs and wherever useful
- show details of selected posting
- add location info
- show details of selected posting group
- filter subaccounts, payee, tag...
- preserve view selection across root changes
- preserve UI state in history (make back/forward buttons work)
- trim to time range on export (need to recalc posting balances!)
- balance charts
- show commodities and prices
- investment performance summary

### Issues

Expand Down
8 changes: 6 additions & 2 deletions cmd/coin2html/js/body.html
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,9 @@ <h1>
</section>
</div>

<script src="src/models.ts"></script>
<script src="src/views.ts"></script>
<script src="src/commodity.ts"></script>
<script src="src/account.ts"></script>
<script src="src/utils.ts"></script>
<script src="src/ui.ts"></script>
<script src="src/register.ts"></script>
<script src="src/chart.ts"></script>
2 changes: 1 addition & 1 deletion cmd/coin2html/js/spec/commodity.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Commodity } from "../src/models";
import { Commodity } from "../src/commodity";

test("create commodity", () =>
expect(new Commodity("CAD", "Canadian Dollar", 2, "")).toBeTruthy());
219 changes: 219 additions & 0 deletions cmd/coin2html/js/src/account.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
import { Amount, Commodities, Commodity } from "./commodity";
import { State } from "./ui";
import {
AccountPostingGroups,
dateToString,
groupBy,
trimToDateRange,
} from "./utils";

export class Account {
children: Account[] = [];
postings: Posting[] = [];
constructor(
readonly name: string,
readonly fullName: string,
readonly commodity: Commodity,
readonly parent: Account,
readonly location: string,
readonly closed?: Date
) {
if (parent) {
parent.children.push(this);
}
}
allChildren(): Account[] {
return this.children.reduce(
(total: Account[], acc: Account) =>
State.ShowClosedAccounts || !acc.closed
? total.concat([acc, ...acc.allChildren()])
: total,
[]
);
}
toString(): string {
return this.fullName;
}
// child name with this account's name prefix stripped
relativeName(child: Account): string {
return child.fullName.slice(this.fullName.length);
}
withAllChildPostings(from: Date, to: Date): Posting[] {
const postings = trimToDateRange(this.postings, from, to).concat(
this.children.map((c) => c.withAllChildPostings(from, to)).flat()
);
postings.sort(
(a, b) => a.transaction.posted.getTime() - b.transaction.posted.getTime()
);
return postings;
}
withAllChildPostingGroups(
from: Date,
to: Date,
groupKey: d3.TimeInterval
): AccountPostingGroups[] {
let accounts = this.allChildren();
accounts.unshift(this);
// drop accounts with no postings
accounts = accounts.filter((a) => a.postings.length > 0);
return accounts.map((acc) => ({
account: acc,
groups: groupBy(
trimToDateRange(acc.postings, from, to),
groupKey,
(p) => p.transaction.posted,
acc.commodity
),
}));
}
withAllParents(): Account[] {
return this.parent ? this.parent.withAllParents().concat(this) : [this];
}
getRootAccount(): Account {
return this.parent ? this.parent.getRootAccount() : this;
}
}

export interface Tags {
[key: string]: string;
}

export class Posting {
index?: number; // used to cache index in the register view for sorting
constructor(
readonly transaction: Transaction,
readonly account: Account,
readonly quantity: Amount,
readonly balance: Amount,
readonly balance_asserted?: boolean,
readonly notes?: string[],
readonly tags?: Tags
) {
transaction.postings.push(this);
account.postings.push(this);
}
toString(): string {
return (
this.account.fullName +
" " +
this.quantity.toString() +
(this.balance_asserted ? " = " + this.balance.toString() : "")
);
}
}

export class Transaction {
postings: Posting[] = [];
constructor(
readonly posted: Date,
readonly description: string,
readonly location: string,
readonly notes?: string[],
readonly code?: string
) {}
toString(): string {
return dateToString(this.posted) + " " + this.description;
}
// return the other posting in this transaction
// less clear in multi-posting transactions
// return first posting that isn't notThis and has the opposite sign
other(notThis: Posting): Posting {
const notThisSign = notThis.quantity.sign;
for (const p of this.postings) {
if (p != notThis && (p.quantity.sign != notThisSign || notThisSign == 0))
return p;
}
throw new Error(`No other posting? ${notThis}`);
}
}

type importedAccounts = Record<
string,
{
name: string;
fullName: string;
commodity: string;
parent: string;
closed?: string;
location: string;
}
>;
type importedTransactions = {
posted: string;
description: string;
location: string;
postings: {
account: string;
balance: string;
balance_asserted: boolean;
quantity: string;
notes?: string[];
tags?: Tags;
}[];
notes?: string[];
code?: string;
tags?: Tags;
}[];

// min and max transaction date from the dataset
export let MinDate = new Date();
export let MaxDate = new Date(0);

export const Accounts: Record<string, Account> = {};
export const Roots: Account[] = [];
export function loadAccounts() {
const importedAccounts = JSON.parse(
document.getElementById("importedAccounts")!.innerText
) as importedAccounts;
for (const impAccount of Object.values(importedAccounts)) {
if (impAccount.name == "Root") continue;
const parent = Accounts[impAccount.parent];
const account = new Account(
impAccount.name,
impAccount.fullName,
Commodities[impAccount.commodity],
parent,
impAccount.location,
impAccount.closed ? new Date(impAccount.closed) : undefined
);
Accounts[account.fullName] = account;
if (!parent) {
Roots.push(account);
}
}

const importedTransactions = JSON.parse(
document.getElementById("importedTransactions")!.innerText
) as importedTransactions;
for (const impTransaction of Object.values(importedTransactions)) {
const posted = new Date(impTransaction.posted);
if (posted < MinDate) MinDate = posted;
if (MaxDate < posted) MaxDate = posted;
const transaction = new Transaction(
posted,
impTransaction.description,
impTransaction.location,
impTransaction.notes,
impTransaction.code
);
for (const impPosting of impTransaction.postings) {
const account = Accounts[impPosting.account];
if (!account) {
throw new Error("Unknown account: " + impPosting.account);
}
const quantity = Amount.parse(impPosting.quantity);
const balance = Amount.parse(impPosting.balance);
const posting = new Posting(
transaction,
account,
quantity,
balance,
impPosting.balance_asserted,
impPosting.notes,
impPosting.tags
);
}
}
MinDate = new Date(MinDate.getFullYear(), 0, 1);
MaxDate = new Date(MaxDate.getFullYear(), 11, 31);
}
Loading

0 comments on commit 9dd7d6e

Please sign in to comment.