Skip to content

Commit

Permalink
Merge pull request #27 from mkobetic/conversion-fixes
Browse files Browse the repository at this point in the history
fix conversion issues
  • Loading branch information
mkobetic authored Dec 20, 2024
2 parents 743848c + aa2f8b2 commit da810a7
Show file tree
Hide file tree
Showing 12 changed files with 2,090 additions and 754 deletions.
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,6 @@
},
"files.insertFinalNewline": true,
"files.trimFinalNewlines": true,
"files.trimTrailingWhitespace": true
"files.trimTrailingWhitespace": true,
"makefile.configureOnOpen": false
}
9 changes: 5 additions & 4 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,16 @@

### coin2html

- make commodity conversions more robust, they blow up too easily
- replace dateToString with d3.format
- tooltips for columns, inputs and wherever useful
- 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...
- replace dateToString with d3.format
- try d3 binning for groupBy utils
- try d3 layouts
- tooltips for columns, inputs and wherever useful
- preserve UI state in history (make back/forward buttons work)
- trim to time range on export (need to recalc posting balances!)
- allow dropping a subaccount from aggregations (in both chart and register)
- balance charts
- show commodities and prices
- investment performance summary
Expand Down
5 changes: 4 additions & 1 deletion cmd/coin2html/js/body.html
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@
>
</section>
<h1>
<output id="account"></output>
<output id="account">
<span id="name"></span>
<span id="commodity"></span>
</output>
</h1>
<section id="view"></section>
</section>
Expand Down
134 changes: 129 additions & 5 deletions cmd/coin2html/js/spec/commodity.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,23 @@
import { Amount, Commodity } from "../src/commodity";
import {
Amount,
Commodities,
commodity,
Commodity,
composeConversions,
newConversion,
Price,
} from "../src/commodity";

test("create commodity", () =>
expect(new Commodity("CAD", "Canadian Dollar", 2, "")).toBeTruthy());
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, "");

describe("amount", () => {
const CAD = new Commodity("CAD", "Canadian Dollar", 2, "");
const CAD = commodity`CAD`;
test.each([
[0, "0.00 CAD"],
[1, "0.01 CAD"],
Expand All @@ -13,8 +26,119 @@ describe("amount", () => {
[-50, "-0.50 CAD"],
[123456789, "1,234,567.89 CAD"],
[-12345678, "-123,456.78 CAD"],
])(`%#: %i`, (i, expected) => {
])(`%#: toString %i`, (i, expected) => {
const amt = new Amount(i, CAD);
expect(amt.toString()).toBe(expected);
});

test.each([
["25.00 CAD", "25.00 CAD"],
["25.0 CAD", "25.00 CAD"],
["25 CAD", "25.00 CAD"],
["-25 CAD", "-25.00 CAD"],
["0.1 CAD", "0.10 CAD"],
["-0.02834 CAD", "-0.02 CAD"],
["25.0330 CAD", "25.03 CAD"],
["25,033.5 CAD", "25,033.50 CAD"],
])(`%#: parse %s`, (input, expected) => {
expect(Amount.parse(input).toString()).toBe(expected);
});

test.each([
["4 CAD", 4, 2500],
["0.25 CAD", 4, 40000],
["4 CAD", 2, 25],
["0.25 CAD", 2, 400],
])(`%#: reciprocal %s/%d`, (input, decimals, expected) => {
expect(Amount.parse(input).reciprocal(decimals)).toBe(expected);
});
});

describe("price", () => {
test.each([
["CAD: 0.75 USD @ 2000-01-01"],
["USD: 1.33 CAD @ 2000-01-01"],
["USD: 0.99 EUR @ 2010-01-01"],
])("toString %s", (input) => {
const price = Price.parse(input);
expect(price.toString()).toBe(input);
});
test.each([
["CAD: 0.75 USD", "USD: 1.33 CAD"],
["USD: 1.33 CAD", "CAD: 0.75 USD"],
])("reverse %s", (input, expected) => {
const price = Price.parse(input);
const reversed = price.reverse().toString().split("@")[0].trim();
expect(reversed).toBe(expected);
});
});

describe("conversions", () => {
test("composition", () => {
const day = new Date("2000-01-01");
const dayString = day.toISOString().split("T")[0];
const cu = newConversion([Price.parse(`CAD: 0.75 USD @ ${dayString}`)]);
const ue = newConversion([Price.parse(`USD: 0.90 EUR @ ${dayString}`)]);
const ce = composeConversions(cu, ue);
expect(ce.direction).toBe("CAD => USD => EUR");
expect(ce(day).toString()).toBe("CAD: 0.68 EUR @ 2000-01-01");
expect(() => composeConversions(ue, cu)).toThrow();

const ez = newConversion([Price.parse(`EUR: 25.00 CZK @ ${dayString}`)]);
const cz = composeConversions(cu, composeConversions(ue, ez));
expect(cz.direction).toBe("CAD => USD => EUR => CZK");
expect(cz(day).toString()).toBe("CAD: 16.88 CZK @ 2000-01-01");
});

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 day = new Date("2000-01-01");
for (const [com, val, com2] of [
[CAD, 75, USD],
[USD, 90, EUR],
[EUR, 2500, CZK],
[CAD, 68, EUR],
] as [Commodity, number, Commodity][]) {
const p = new Price(com, day, new Amount(val, com2), "");
com.prices.push(p);
com2.prices.push(p.reverse());
}
test.each([
[350, CAD, 350, USD, "8.16 CAD"],
[350, USD, 350, CAD, "6.13 USD"],
[350, EUR, 350, USD, "6.65 EUR"],
[350, USD, 350, EUR, "7.39 USD"],
[350, EUR, 3500, CZK, "4.90 EUR"],
[3500, CZK, 350, EUR, "122.50 CZK"],
[350, CAD, 350, EUR, "8.65 CAD"],
[350, EUR, 350, CAD, "5.88 EUR"],
[3500, CZK, 350, CAD, "94.50 CZK"],
[3500, CZK, 350, USD, "113.75 CZK"],
[350, CAD, 3500, CZK, "5.60 CAD"],
[350, USD, 3500, CZK, "4.90 USD"],
])(`%#: %d %s + %d %s`, (fv, fc, tv, tc, exp) => {
expect(new Amount(fv, fc).addIn(new Amount(tv, tc), day).toString()).toBe(
exp
);
});
test.each([
[CAD, CZK, "17.00 CZK"],
[USD, CZK, "22.50 CZK"],
[CZK, USD, "0.04 USD"],
[CZK, CAD, "0.06 CAD"],
])(`%#: %s -> %s`, (from, to, exp) => {
expect(to.convert(new Amount(100, from), day).toString()).toBe(exp);
});
test.each([
[CAD, ["CAD => USD", "CAD => EUR", "CAD => EUR => CZK"]],
[USD, ["USD => CAD", "USD => EUR", "USD => EUR => CZK"]],
[EUR, ["EUR => USD", "EUR => CZK", "EUR => CAD"]],
[CZK, ["CZK => EUR", "CZK => EUR => CAD", "CZK => EUR => USD"]],
])(`%#: %s conversions`, (c, exp) => {
expect([...c.conversions.values()].map((c) => c.direction)).toEqual(exp);
});
});
});
20 changes: 11 additions & 9 deletions cmd/coin2html/js/src/account.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Amount, Commodities, Commodity } from "./commodity";
import { Amount, Commodity } from "./commodity";
import { State } from "./views";
import {
AccountPostingGroups,
Expand All @@ -7,6 +7,10 @@ import {
trimToDateRange,
} from "./utils";

/**
* Account, Posting and Transaction
*/

export class Account {
children: Account[] = [];
postings: Posting[] = [];
Expand Down Expand Up @@ -166,17 +170,15 @@ 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;
export function loadAccounts(source: string) {
const importedAccounts = JSON.parse(source) 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],
Commodity.find(impAccount.commodity),
parent,
impAccount.location,
impAccount.closed ? new Date(impAccount.closed) : undefined
Expand All @@ -186,10 +188,10 @@ export function loadAccounts() {
Roots.push(account);
}
}
}

const importedTransactions = JSON.parse(
document.getElementById("importedTransactions")!.innerText
) as importedTransactions;
export function loadTransactions(source: string) {
const importedTransactions = JSON.parse(source) as importedTransactions;
for (const impTransaction of Object.values(importedTransactions)) {
const posted = new Date(impTransaction.posted);
if (posted < MinDate) MinDate = posted;
Expand Down
23 changes: 16 additions & 7 deletions cmd/coin2html/js/src/chart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ import {
AggregationStyle,
addAggregationStyleInput,
} from "./views";
import { groupWithSubAccounts, PostingGroup } from "./utils";
import {
groupByWithSubAccounts,
PostingGroup,
shortenAccountName,
} from "./utils";
import { Account } from "./account";
import { axisLeft, axisTop } from "d3-axis";
import { scaleLinear, scaleOrdinal, scaleTime } from "d3-scale";
Expand All @@ -33,21 +37,26 @@ export function viewChart(options?: {
const groupKey = Aggregation[State.View.Aggregate] as d3.TimeInterval;
const dates = groupKey.range(State.StartDate, State.EndDate);
const maxAccounts = State.View.AggregatedSubAccountMax;
const accountGroups = groupWithSubAccounts(account, groupKey, maxAccounts, {
const accountGroups = groupByWithSubAccounts(account, groupKey, maxAccounts, {
negated: opts.negated,
});
const maxLabelLength = Math.round(180 / State.View.AggregatedSubAccountMax);
const labelFromAccount = (a: Account | undefined) =>
a ? account.relativeName(a) : "Other";
a ? shortenAccountName(account.relativeName(a), maxLabelLength) : "Other";
const labels = accountGroups.map((gs) => labelFromAccount(gs.account));
// compute offsets for each group left to right
// and max width for the x domain
let max = 0;
const widthFromGroup = (group: PostingGroup) => {
let width = Math.trunc(
(State.View.AggregationStyle == AggregationStyle.Flows
? group.sum
: group.balance
).toNumber()
account.commodity
.convert(
State.View.AggregationStyle == AggregationStyle.Flows
? group.sum
: group.balance,
group.date
)
.toNumber()
);
if (opts.negated) width = -width;
return width < 0 ? 0 : width;
Expand Down
Loading

0 comments on commit da810a7

Please sign in to comment.