summaryrefslogtreecommitdiff
path: root/gui/src/types
diff options
context:
space:
mode:
authorJoe Carstairs <me@joeac.net>2024-09-17 13:20:04 +0100
committerJoe Carstairs <me@joeac.net>2024-09-17 13:20:04 +0100
commitcd269b3540fb5e3a988c1b59d3763db360b1fb34 (patch)
treea728403d8d5708ad29eeed2b391706b09a93fe4c /gui/src/types
parentf0e3e0cfb183253e03b1a61ddecb7b9330d47fdb (diff)
many, many changes
Diffstat (limited to 'gui/src/types')
-rw-r--r--gui/src/types/BalanceToBudgetRatio.spec.ts307
-rw-r--r--gui/src/types/account.ts26
-rw-r--r--gui/src/types/balanceMap.ts17
-rw-r--r--gui/src/types/balanceToBudgetRatio.ts83
-rw-r--r--gui/src/types/budget.ts12
-rw-r--r--gui/src/types/budgetMap.ts16
-rw-r--r--gui/src/types/category.ts51
-rw-r--r--gui/src/types/currency/gbp.ts10
-rw-r--r--gui/src/types/dtos/accountDto.ts8
9 files changed, 506 insertions, 24 deletions
diff --git a/gui/src/types/BalanceToBudgetRatio.spec.ts b/gui/src/types/BalanceToBudgetRatio.spec.ts
new file mode 100644
index 0000000..3a956ea
--- /dev/null
+++ b/gui/src/types/BalanceToBudgetRatio.spec.ts
@@ -0,0 +1,307 @@
+import { describe, expect, it } from "vitest";
+import { BalanceToBudgetRatio } from "./balanceToBudgetRatio";
+import { Gbp } from "./currency/gbp";
+
+describe("balanceToBudgetRatio", () => {
+ it("new() returns null when budget is null", () => {
+ const result = BalanceToBudgetRatio.new(new Gbp(1, 0, 1), null);
+ expect(result).toBeNull();
+ });
+
+ it("toString() rounds to 2 decimal places", () => {
+ const mockCurrencyPi = { valueOf: () => Math.PI };
+ const mockCurrencyUnit = { valueOf: () => 1 };
+ const sut = BalanceToBudgetRatio.new(mockCurrencyPi, {
+ quantity: mockCurrencyUnit,
+ period: 42,
+ });
+
+ const result = sut?.toString();
+
+ expect(result).toBe("3.14");
+ });
+
+ it("new() sets normalisedRatio to 1 when balance and budget are equal and positive", () => {
+ const twoFifty = new Gbp(2, 50, 1);
+ const result = BalanceToBudgetRatio.new(twoFifty, {
+ quantity: twoFifty,
+ period: 42,
+ });
+ expect(result?.normalisedRatio).toBeCloseTo(1.0);
+ });
+
+ it("new() sets normalisedRatio to 2 when balance is twice budget", () => {
+ const twoFifty = new Gbp(2, 50, 1);
+ const oneTwentyFive = new Gbp(1, 25, 1);
+ const result = BalanceToBudgetRatio.new(twoFifty, {
+ quantity: oneTwentyFive,
+ period: 42,
+ });
+ expect(result?.normalisedRatio).toBeCloseTo(2.0);
+ });
+
+ it("new() sets normalisedRatio to -2 when a negative balance is -1 * twice budget", () => {
+ const minusTwoFifty = new Gbp(2, 50, -1);
+ const oneTwentyFive = new Gbp(1, 25, 1);
+ const result = BalanceToBudgetRatio.new(minusTwoFifty, {
+ quantity: oneTwentyFive,
+ period: 42,
+ });
+ expect(result?.normalisedRatio).toBeCloseTo(-2.0);
+ });
+
+ it("new() sets normalisedRatio to -0.5 when balance is -1 * half a negative budget", () => {
+ const oneTwentyFive = new Gbp(1, 25, 1);
+ const minusTwoFifty = new Gbp(2, 50, -1);
+ const result = BalanceToBudgetRatio.new(oneTwentyFive, {
+ quantity: minusTwoFifty,
+ period: 42,
+ });
+ expect(result?.normalisedRatio).toBeCloseTo(-0.5);
+ });
+
+ it("new() sets normalisedRatio to 1 when balance and budget are equal and negative", () => {
+ const minusTwoFifty = new Gbp(2, 50, -1);
+ const result = BalanceToBudgetRatio.new(minusTwoFifty, {
+ quantity: minusTwoFifty,
+ period: 42,
+ });
+ expect(result?.normalisedRatio).toBeCloseTo(1.0);
+ });
+
+ it('new() sets recommended action to "arrears" when ratio is less than 0', () => {
+ const oneTwentyFive = new Gbp(1, 25, 1);
+ const minusTwoFifty = new Gbp(2, 50, -1);
+
+ const result = BalanceToBudgetRatio.new(oneTwentyFive, {
+ quantity: minusTwoFifty,
+ period: 42,
+ });
+
+ expect(result?.recommendedAction).toBe("arrears");
+ });
+
+ it('new() sets recommended action to "save" when ratio is between 0 and 1', () => {
+ const oneTwentyFive = new Gbp(1, 25, 1);
+ const twoFifty = new Gbp(2, 50, 1);
+
+ const result = BalanceToBudgetRatio.new(oneTwentyFive, {
+ quantity: twoFifty,
+ period: 42,
+ });
+
+ expect(result?.recommendedAction).toBe("save");
+ });
+
+ it('new() sets recommended action to "spend" when ratio is larger than 1', () => {
+ const oneTwentyFive = new Gbp(1, 25, 1);
+ const twoFifty = new Gbp(2, 50, 1);
+
+ const result = BalanceToBudgetRatio.new(twoFifty, {
+ quantity: oneTwentyFive,
+ period: 42,
+ });
+
+ expect(result?.recommendedAction).toBe("spend");
+ });
+
+ it("normalises to a factor of 2 when all ratios are positive", () => {
+ const oneTwentyFive = new Gbp(1, 25, 1);
+ const twoFifty = new Gbp(2, 50, 1);
+ const twoOverOne = BalanceToBudgetRatio.new(twoFifty, {
+ quantity: oneTwentyFive,
+ period: 42,
+ });
+ const twoOverTwo = BalanceToBudgetRatio.new(twoFifty, {
+ quantity: twoFifty,
+ period: 42,
+ });
+ const oneOverOne = BalanceToBudgetRatio.new(oneTwentyFive, {
+ quantity: oneTwentyFive,
+ period: 42,
+ });
+ const oneOverTwo = BalanceToBudgetRatio.new(oneTwentyFive, {
+ quantity: twoFifty,
+ period: 42,
+ });
+
+ const normaliser = BalanceToBudgetRatio.makeNormaliser(
+ twoOverOne,
+ twoOverTwo,
+ oneOverOne,
+ oneOverTwo,
+ );
+
+ expect(normaliser(twoOverOne).normalisedRatio).toBeCloseTo(1.0);
+ expect(normaliser(twoOverTwo).normalisedRatio).toBeCloseTo(0.5);
+ expect(normaliser(oneOverOne).normalisedRatio).toBeCloseTo(0.5);
+ expect(normaliser(oneOverTwo).normalisedRatio).toBeCloseTo(0.25);
+ });
+
+ it("normalises to a factor of 2 when all ratios are negative", () => {
+ const oneTwentyFive = new Gbp(1, 25, 1);
+ const twoFifty = new Gbp(2, 50, 1);
+ const minusOneTwentyFive = new Gbp(1, 25, -1);
+ const minusTwoFifty = new Gbp(2, 50, -1);
+ const minusTwoOverOne = BalanceToBudgetRatio.new(minusTwoFifty, {
+ quantity: oneTwentyFive,
+ period: 42,
+ });
+ const minusTwoOverTwo = BalanceToBudgetRatio.new(minusTwoFifty, {
+ quantity: twoFifty,
+ period: 42,
+ });
+ const minusOneOverOne = BalanceToBudgetRatio.new(minusOneTwentyFive, {
+ quantity: oneTwentyFive,
+ period: 42,
+ });
+ const minusOneOverTwo = BalanceToBudgetRatio.new(minusOneTwentyFive, {
+ quantity: twoFifty,
+ period: 42,
+ });
+
+ const normaliser = BalanceToBudgetRatio.makeNormaliser(
+ minusTwoOverOne,
+ minusTwoOverTwo,
+ minusOneOverOne,
+ minusOneOverTwo,
+ );
+
+ expect(normaliser(minusTwoOverOne).normalisedRatio).toBeCloseTo(-1.0);
+ expect(normaliser(minusTwoOverTwo).normalisedRatio).toBeCloseTo(-0.5);
+ expect(normaliser(minusOneOverOne).normalisedRatio).toBeCloseTo(-0.5);
+ expect(normaliser(minusOneOverTwo).normalisedRatio).toBeCloseTo(-0.25);
+ });
+
+ it("normalises to a factor of 2 when ratios are a mix of positive and negative", () => {
+ const oneTwentyFive = new Gbp(1, 25, 1);
+ const twoFifty = new Gbp(2, 50, 1);
+ const minusOneTwentyFive = new Gbp(1, 25, -1);
+ const minusTwoFifty = new Gbp(2, 50, -1);
+ const twoOverOne = BalanceToBudgetRatio.new(twoFifty, {
+ quantity: oneTwentyFive,
+ period: 42,
+ });
+ const twoOverTwo = BalanceToBudgetRatio.new(twoFifty, {
+ quantity: twoFifty,
+ period: 42,
+ });
+ const oneOverOne = BalanceToBudgetRatio.new(oneTwentyFive, {
+ quantity: oneTwentyFive,
+ period: 42,
+ });
+ const oneOverTwo = BalanceToBudgetRatio.new(oneTwentyFive, {
+ quantity: twoFifty,
+ period: 42,
+ });
+ const minusTwoOverOne = BalanceToBudgetRatio.new(minusTwoFifty, {
+ quantity: oneTwentyFive,
+ period: 42,
+ });
+ const minusTwoOverTwo = BalanceToBudgetRatio.new(minusTwoFifty, {
+ quantity: twoFifty,
+ period: 42,
+ });
+ const minusOneOverOne = BalanceToBudgetRatio.new(minusOneTwentyFive, {
+ quantity: oneTwentyFive,
+ period: 42,
+ });
+ const minusOneOverTwo = BalanceToBudgetRatio.new(minusOneTwentyFive, {
+ quantity: twoFifty,
+ period: 42,
+ });
+
+ const normaliser = BalanceToBudgetRatio.makeNormaliser(
+ twoOverOne,
+ twoOverTwo,
+ oneOverOne,
+ oneOverTwo,
+ minusTwoOverOne,
+ minusTwoOverTwo,
+ minusOneOverOne,
+ minusOneOverTwo,
+ );
+
+ expect(normaliser(twoOverOne).normalisedRatio).toBeCloseTo(1.0);
+ expect(normaliser(twoOverTwo).normalisedRatio).toBeCloseTo(0.5);
+ expect(normaliser(oneOverOne).normalisedRatio).toBeCloseTo(0.5);
+ expect(normaliser(oneOverTwo).normalisedRatio).toBeCloseTo(0.25);
+ expect(normaliser(minusTwoOverOne).normalisedRatio).toBeCloseTo(-1.0);
+ expect(normaliser(minusTwoOverTwo).normalisedRatio).toBeCloseTo(-0.5);
+ expect(normaliser(minusOneOverOne).normalisedRatio).toBeCloseTo(-0.5);
+ expect(normaliser(minusOneOverTwo).normalisedRatio).toBeCloseTo(-0.25);
+ });
+
+ it("sorts large positive ratios before small positive ratios", () => {
+ const oneTwentyFive = new Gbp(1, 25, 1);
+ const twoFifty = new Gbp(2, 50, 1);
+ const twoOverTwo = BalanceToBudgetRatio.new(twoFifty, {
+ quantity: twoFifty,
+ period: 42,
+ });
+ const oneOverTwo = BalanceToBudgetRatio.new(oneTwentyFive, {
+ quantity: twoFifty,
+ period: 42,
+ });
+
+ const resultLeftways = BalanceToBudgetRatio.compare(twoOverTwo, oneOverTwo);
+ const resultRightways = BalanceToBudgetRatio.compare(
+ oneOverTwo,
+ twoOverTwo,
+ );
+
+ expect(resultLeftways).toBeCloseTo(-resultRightways);
+ expect(resultLeftways).toBeLessThan(0);
+ });
+
+ it("sorts small negative ratios before large negative ratios", () => {
+ const twoFifty = new Gbp(2, 50, 1);
+ const minusOneTwentyFive = new Gbp(1, 25, -1);
+ const minusTwoFifty = new Gbp(2, 50, -1);
+ const minusTwoOverTwo = BalanceToBudgetRatio.new(minusTwoFifty, {
+ quantity: twoFifty,
+ period: 42,
+ });
+ const minusOneOverTwo = BalanceToBudgetRatio.new(minusOneTwentyFive, {
+ quantity: twoFifty,
+ period: 42,
+ });
+
+ const resultLeftways = BalanceToBudgetRatio.compare(
+ minusOneOverTwo,
+ minusTwoOverTwo,
+ );
+ const resultRightways = BalanceToBudgetRatio.compare(
+ minusTwoOverTwo,
+ minusOneOverTwo,
+ );
+
+ expect(resultLeftways).toBeCloseTo(-resultRightways);
+ expect(resultLeftways).toBeLessThan(0);
+ });
+
+ it("sorts positive ratios before negative ratios", () => {
+ const twoFifty = new Gbp(2, 50, 1);
+ const minusTwoFifty = new Gbp(2, 50, -1);
+ const twoOverTwo = BalanceToBudgetRatio.new(twoFifty, {
+ quantity: twoFifty,
+ period: 42,
+ });
+ const minusTwoOverTwo = BalanceToBudgetRatio.new(minusTwoFifty, {
+ quantity: twoFifty,
+ period: 42,
+ });
+
+ const resultLeftways = BalanceToBudgetRatio.compare(
+ twoOverTwo,
+ minusTwoOverTwo,
+ );
+ const resultRightways = BalanceToBudgetRatio.compare(
+ minusTwoOverTwo,
+ twoOverTwo,
+ );
+
+ expect(resultLeftways).toBeCloseTo(-resultRightways);
+ expect(resultLeftways).toBeLessThan(0);
+ });
+});
diff --git a/gui/src/types/account.ts b/gui/src/types/account.ts
index 4a3626c..a463304 100644
--- a/gui/src/types/account.ts
+++ b/gui/src/types/account.ts
@@ -1,5 +1,27 @@
-import { Moment } from "moment";
+import moment, { Moment } from "moment";
import Currency from "./currency/currency";
+import Gbp from "./currency/gbp";
+
+function newAccountFromDto(dto: unknown): Account {
+ const dtoDangerous = dto as AccountDto;
+ return {
+ id: dtoDangerous.id,
+ name: dtoDangerous.name,
+ openingBalance: new Gbp(
+ Math.floor(Math.abs(dtoDangerous.opening_balance) / 100),
+ Math.abs(dtoDangerous.opening_balance) % 100,
+ dtoDangerous.opening_balance < 0 ? -1 : 1,
+ ),
+ openingDate: moment(dtoDangerous.opening_date),
+ };
+}
+
+interface AccountDto {
+ readonly id: number;
+ readonly name: string;
+ readonly opening_balance: number;
+ readonly opening_date: string;
+}
interface Account {
readonly id: number;
@@ -8,4 +30,4 @@ interface Account {
readonly openingDate: Moment;
}
-export default Account;
+export { type Account, newAccountFromDto };
diff --git a/gui/src/types/balanceMap.ts b/gui/src/types/balanceMap.ts
new file mode 100644
index 0000000..760843f
--- /dev/null
+++ b/gui/src/types/balanceMap.ts
@@ -0,0 +1,17 @@
+import { Category } from "./category";
+import Currency from "./currency/currency";
+import { newGbpFromDto } from "./currency/gbp";
+
+type BalanceMap<T extends { id: any }> = {
+ [id in T["id"]]: Currency;
+};
+
+function newCategoryBalanceMapFromDto(dto: unknown): BalanceMap<Category> {
+ return Object.fromEntries(
+ (dto as { category_id: Category["id"]; balance: number }[]).map(
+ ({ category_id, balance }) => [category_id, newGbpFromDto(balance)],
+ ),
+ );
+}
+
+export { type BalanceMap, newCategoryBalanceMapFromDto };
diff --git a/gui/src/types/balanceToBudgetRatio.ts b/gui/src/types/balanceToBudgetRatio.ts
new file mode 100644
index 0000000..2f8000c
--- /dev/null
+++ b/gui/src/types/balanceToBudgetRatio.ts
@@ -0,0 +1,83 @@
+import { Budget } from "./budget";
+import Currency from "./currency/currency";
+
+class BalanceToBudgetRatio {
+ toString(): string {
+ return this._ratio?.toFixed(2) ?? "";
+ }
+
+ public readonly recommendedAction: "arrears" | "save" | "spend" | null;
+ public readonly normalisedRatio: number | null;
+
+ private constructor(
+ private readonly _ratio: number | null,
+ private readonly _normalisationFactor: number,
+ ) {
+ if (this._ratio === null) {
+ this.normalisedRatio = null;
+ this.recommendedAction = null;
+ return;
+ }
+
+ this.normalisedRatio = this._ratio * this._normalisationFactor;
+
+ if (this._ratio < 0) {
+ this.recommendedAction = "arrears";
+ } else if (this._ratio >= 1) {
+ this.recommendedAction = "spend";
+ } else {
+ this.recommendedAction = "save";
+ }
+ }
+
+ static new(balance: Currency, budget: Budget | null) {
+ if (budget === null || budget.quantity === 0 || budget.quantity === -0) {
+ return null;
+ }
+
+ return new BalanceToBudgetRatio(
+ balance.valueOf() / budget.quantity.valueOf(),
+ 1,
+ );
+ }
+
+ static makeNormaliser(
+ ...balanceToBudgetRatios: (BalanceToBudgetRatio | null)[]
+ ): (r: BalanceToBudgetRatio | null) => BalanceToBudgetRatio {
+ const validRatios = balanceToBudgetRatios.filter(
+ (r) =>
+ r &&
+ r._ratio &&
+ !isNaN(r._ratio) &&
+ Infinity > r._ratio &&
+ -Infinity < r._ratio,
+ );
+
+ const maxAbsRatio = Math.max(
+ ...validRatios.map((r) => Math.abs(r?._ratio ?? 0)),
+ );
+
+ const normalisationFactor = 1 / maxAbsRatio;
+ return (r) =>
+ new BalanceToBudgetRatio(r?._ratio ?? null, normalisationFactor);
+ }
+
+ static compare(
+ lhs: BalanceToBudgetRatio | null,
+ rhs: BalanceToBudgetRatio | null,
+ ): number {
+ if (lhs === null || lhs._ratio === null) {
+ if (rhs === null || rhs._ratio === null) {
+ return 0;
+ }
+ return 1;
+ }
+ if (rhs === null || rhs._ratio === null) {
+ return -1;
+ }
+
+ return rhs._ratio - lhs._ratio;
+ }
+}
+
+export { BalanceToBudgetRatio };
diff --git a/gui/src/types/budget.ts b/gui/src/types/budget.ts
index 86bae2c..5a19a1b 100644
--- a/gui/src/types/budget.ts
+++ b/gui/src/types/budget.ts
@@ -1,8 +1,12 @@
import Currency from "./currency/currency";
interface Budget {
- quantity: Currency,
- period: number,
-};
+ quantity: Currency;
+ period: number;
+}
-export default Budget;
+function newBudgetFromDto(dto: unknown) {
+ return dto as Budget;
+}
+
+export { type Budget, newBudgetFromDto };
diff --git a/gui/src/types/budgetMap.ts b/gui/src/types/budgetMap.ts
new file mode 100644
index 0000000..5fa09f2
--- /dev/null
+++ b/gui/src/types/budgetMap.ts
@@ -0,0 +1,16 @@
+import { Budget, newBudgetFromDto } from "./budget";
+import { Category } from "./category";
+
+type BudgetMap<T extends { id: any }> = {
+ [id in T["id"]]: Budget | null;
+};
+
+function newCategoryBudgetMapFromDto(dto: unknown): BudgetMap<Category> {
+ return Object.fromEntries(
+ (dto as { category_id: number; budget: unknown }[]).map(
+ ({ category_id, budget }) => [category_id, newBudgetFromDto(budget)],
+ ),
+ );
+}
+
+export { type BudgetMap, newCategoryBudgetMapFromDto as newBudgetMapFromDto };
diff --git a/gui/src/types/category.ts b/gui/src/types/category.ts
index af4526b..005c041 100644
--- a/gui/src/types/category.ts
+++ b/gui/src/types/category.ts
@@ -1,12 +1,45 @@
-import Budget from "./budget";
+import { BalanceMap } from "./balanceMap";
+import { BalanceToBudgetRatio } from "./balanceToBudgetRatio";
+import { Budget } from "./budget";
+import { BudgetMap } from "./budgetMap";
import Currency from "./currency/currency";
interface Category {
- id: number,
- name: string,
- currentBalance: Currency,
- currentBudget: Budget,
- balanceToBudgetRatio: number,
-};
-
-export default Category;
+ id: number;
+ name: string;
+ currentBalance: Currency;
+ currentBudget: Budget | null;
+ balanceToBudgetRatio: BalanceToBudgetRatio | null;
+}
+
+interface CategoryDto {
+ id: number;
+ name: string;
+}
+
+function newCategoryFromDto(
+ balances: BalanceMap<Category> | null,
+ budgets: BudgetMap<Category> | null,
+): (dto: unknown) => Category | null {
+ if (balances === null || budgets === null) {
+ return () => null;
+ }
+
+ return function (dto: unknown): Category {
+ const dtoDangerous = dto as CategoryDto;
+ const currentBalance = balances[dtoDangerous.id];
+ const currentBudget = budgets[dtoDangerous.id];
+ return {
+ id: dtoDangerous.id,
+ name: dtoDangerous.name,
+ currentBalance,
+ currentBudget,
+ balanceToBudgetRatio: BalanceToBudgetRatio.new(
+ currentBalance,
+ currentBudget,
+ ),
+ };
+ };
+}
+
+export { type Category, newCategoryFromDto };
diff --git a/gui/src/types/currency/gbp.ts b/gui/src/types/currency/gbp.ts
index ce24e48..b88aae3 100644
--- a/gui/src/types/currency/gbp.ts
+++ b/gui/src/types/currency/gbp.ts
@@ -43,4 +43,12 @@ class Gbp implements Currency {
}
}
-export default Gbp;
+function newGbpFromDto(dto: unknown): Gbp {
+ const signedPence = dto as number;
+ const pounds = Math.floor(Math.abs(signedPence) / 100);
+ const pence = Math.abs(signedPence) % 100;
+ const sign = signedPence < 0 ? -1 : 1;
+ return new Gbp(pounds, pence, sign);
+}
+
+export { Gbp, newGbpFromDto };
diff --git a/gui/src/types/dtos/accountDto.ts b/gui/src/types/dtos/accountDto.ts
deleted file mode 100644
index 0ed7622..0000000
--- a/gui/src/types/dtos/accountDto.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-interface AccountDto {
- readonly id: number;
- readonly name: string;
- readonly openingBalance: number;
- readonly openingDate: string;
-}
-
-export default AccountDto;