diff options
| author | Joe Carstairs <me@joeac.net> | 2024-09-17 13:20:04 +0100 |
|---|---|---|
| committer | Joe Carstairs <me@joeac.net> | 2024-09-17 13:20:04 +0100 |
| commit | cd269b3540fb5e3a988c1b59d3763db360b1fb34 (patch) | |
| tree | a728403d8d5708ad29eeed2b391706b09a93fe4c /gui/src/types | |
| parent | f0e3e0cfb183253e03b1a61ddecb7b9330d47fdb (diff) | |
many, many changes
Diffstat (limited to 'gui/src/types')
| -rw-r--r-- | gui/src/types/BalanceToBudgetRatio.spec.ts | 307 | ||||
| -rw-r--r-- | gui/src/types/account.ts | 26 | ||||
| -rw-r--r-- | gui/src/types/balanceMap.ts | 17 | ||||
| -rw-r--r-- | gui/src/types/balanceToBudgetRatio.ts | 83 | ||||
| -rw-r--r-- | gui/src/types/budget.ts | 12 | ||||
| -rw-r--r-- | gui/src/types/budgetMap.ts | 16 | ||||
| -rw-r--r-- | gui/src/types/category.ts | 51 | ||||
| -rw-r--r-- | gui/src/types/currency/gbp.ts | 10 | ||||
| -rw-r--r-- | gui/src/types/dtos/accountDto.ts | 8 |
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; |
