summaryrefslogtreecommitdiff
path: root/gui/src
diff options
context:
space:
mode:
Diffstat (limited to 'gui/src')
-rw-r--r--gui/src/ajax/backend.ts57
-rw-r--r--gui/src/components/app/App.tsx70
-rw-r--r--gui/src/components/histogram/Histogram.tsx44
-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
12 files changed, 605 insertions, 96 deletions
diff --git a/gui/src/ajax/backend.ts b/gui/src/ajax/backend.ts
index d58e513..90a7ea5 100644
--- a/gui/src/ajax/backend.ts
+++ b/gui/src/ajax/backend.ts
@@ -1,5 +1,58 @@
+import { BalanceMap, newCategoryBalanceMapFromDto } from "../types/balanceMap";
+import { BudgetMap, newBudgetMapFromDto } from "../types/budgetMap";
+import { Category, newCategoryFromDto } from "../types/category";
+
const backendUrl = "http://localhost:8000";
-async function getAllAccounts(): Promise<Account[]> {
- return;
+function makeGetter<T>(params: FetcherMakerParams<T>): Getter<T> {
+ const parser =
+ "parser" in params
+ ? params.parser
+ : (dto: unknown[]) => <T>dto.map(params.elementParser);
+
+ return async function () {
+ const response = await fetch(`${backendUrl}${params.path}`);
+ const dto = await response.json();
+
+ try {
+ return parser(dto);
+ } catch (err) {
+ console.error(`Error parsing DTO. ${err?.toString() ?? ""}`);
+ return null;
+ }
+ };
}
+
+export const getAllCategories: Getter<Category[]> = async () => {
+ const balances = await getAllCategoryBalances();
+ const budgets = await getAllCurrentCategoryBudgets();
+ return makeGetter<Category[]>({
+ path: "/category",
+ elementParser: newCategoryFromDto(balances, budgets),
+ })();
+};
+
+export const getAllCategoryBalances: Getter<BalanceMap<Category>> = makeGetter<
+ BalanceMap<Category>
+>({
+ path: "/balance/category",
+ parser: newCategoryBalanceMapFromDto,
+});
+
+export const getAllCurrentCategoryBudgets: Getter<BudgetMap<Category>> =
+ makeGetter<BudgetMap<Category>>({
+ path: "/budget/category?current",
+ parser: newBudgetMapFromDto,
+ });
+
+type FetcherMakerParams<T> = {
+ path: string;
+} & AnyParser<T>;
+
+type Parser<T> = { parser: (dto: unknown) => T | null };
+type ElementParser<T> = { elementParser: (dto: unknown) => T | null };
+type AnyParser<T> = T extends any[]
+ ? Parser<T> | ElementParser<T[number]>
+ : Parser<T>;
+
+type Getter<T> = () => Promise<T | null>;
diff --git a/gui/src/components/app/App.tsx b/gui/src/components/app/App.tsx
index cd9f9ee..e9ef742 100644
--- a/gui/src/components/app/App.tsx
+++ b/gui/src/components/app/App.tsx
@@ -1,53 +1,33 @@
-import Category from '../../types/category';
-import Gbp from '../../types/currency/gbp';
+import { createResource, Match, Resource, Switch } from 'solid-js';
import Histogram from '../histogram/Histogram'
import './App.css'
+import { getAllCategories } from '../../ajax/backend';
+import { Category } from '../../types/category';
function App() {
+ const [categories] = createResource(getAllCategories);
+
return (
- <>
- <Histogram categories={categories} />
- </>
- )
+ <Switch>
+ <Match when={categories.state === 'ready'}>
+ <Histogram categories={(categories as Extract<Resource<Category[]>, { state: 'ready' }>)()} />
+ </Match>
+ <Match when={categories.state === 'pending'}>
+ <p>Loading...</p>
+ </Match>
+ <Match when={categories.state === 'unresolved'}>
+ <p>Unresolved!</p>
+ </Match>
+ <Match when={categories.state === 'errored'}>
+ <p>Error!</p>
+ <p>{JSON.stringify(categories.error, null, 2)}</p>
+ <p>{JSON.stringify(categories(), null, 2)}</p>
+ </Match>
+ <Match when={categories.state === 'refreshing'}>
+ <p>Refreshing!</p>
+ </Match>
+ </Switch>
+ );
}
-const categories: Category[] = [
- {
- id: 0,
- name: 'Groceries',
- currentBalance: new Gbp(71, 37, 1),
- currentBudget: {
- quantity: new Gbp(65, 0, 1),
- period: 7,
- },
- },
- {
- id: 1,
- name: 'Stationery',
- currentBalance: new Gbp(10, 12, -1),
- currentBudget: {
- quantity: new Gbp(12, 0, 1),
- period: 30,
- },
- },
- {
- id: 2,
- name: 'Holidays',
- currentBalance: new Gbp(325, 0, 1),
- currentBudget: {
- quantity: new Gbp(1000, 0, 1),
- period: 365,
- },
- },
- {
- id: 3,
- name: 'Wages',
- currentBalance: new Gbp(1025, 60, -1),
- currentBudget: {
- quantity: new Gbp(2167, 7, -1),
- period: 30.25,
- },
- },
-].map(c => ({ ...c, balanceToBudgetRatio: c.currentBalance.valueOf() / c.currentBudget.quantity.valueOf() }));
-
export default App
diff --git a/gui/src/components/histogram/Histogram.tsx b/gui/src/components/histogram/Histogram.tsx
index ee4b7b0..3a8309b 100644
--- a/gui/src/components/histogram/Histogram.tsx
+++ b/gui/src/components/histogram/Histogram.tsx
@@ -1,10 +1,18 @@
-import Category from "../../types/category";
+import { BalanceToBudgetRatio } from "../../types/balanceToBudgetRatio";
+import { Category } from "../../types/category";
import './Histogram.scss';
function Histogram({ categories }: Props) {
- const maxAbsBalanceToBudgetRatio = Math.max(
- ...categories.map((c) => Math.abs(c.balanceToBudgetRatio))
- );
+ if (categories === null) {
+ return (
+ <p>No categories to show</p>
+ );
+ }
+
+ const balanceToBudgetRatioNormaliser = BalanceToBudgetRatio.makeNormaliser(...categories.map(c => c.balanceToBudgetRatio));
+ for (const category of categories) {
+ category.balanceToBudgetRatio = balanceToBudgetRatioNormaliser(category.balanceToBudgetRatio);
+ }
return (
<table>
@@ -19,27 +27,24 @@ function Histogram({ categories }: Props) {
<tbody>
{
categories
- .sort((c1, c2) => c1.balanceToBudgetRatio - c2.balanceToBudgetRatio)
- .map((category) => Row({ category, maxAbsBalanceToBudgetRatio }))
+ .sort((c1, c2) => BalanceToBudgetRatio.compare(c1.balanceToBudgetRatio, c2.balanceToBudgetRatio))
+ .map((category) => Row({ category }))
}
</tbody>
</table>
);
}
-function Row({ category, maxAbsBalanceToBudgetRatio }: RowProps) {
- const normalisedRatio = category.balanceToBudgetRatio / maxAbsBalanceToBudgetRatio;
- const variant = getRowVariant(new Number(normalisedRatio).valueOf());
-
+function Row({ category }: RowProps) {
return (
<tr>
<td class="name">{category.name}</td>
<td
- class={`ratio ratio--${variant}`}
- style={`--normal-abs-ratio: ${Math.abs(normalisedRatio)};`}
+ class={`ratio ratio--${category.balanceToBudgetRatio?.recommendedAction ?? ''}`}
+ style={`--normal-abs-ratio: ${Math.abs(category.balanceToBudgetRatio?.normalisedRatio ?? 0)};`}
>
<span class="ratio__text">
- {category.balanceToBudgetRatio.toFixed(2)}
+ {category.balanceToBudgetRatio?.toString() ?? ''}
</span>
<span class="ratio__bar"></span>
</td>
@@ -48,23 +53,12 @@ function Row({ category, maxAbsBalanceToBudgetRatio }: RowProps) {
);
}
-function getRowVariant(balanceToBudgetRatio: number) {
- if (balanceToBudgetRatio < 0) {
- return 'arrears';
- }
- if (balanceToBudgetRatio >= 1) {
- return 'spend';
- }
- return 'save';
-}
-
type Props = {
- categories: Category[],
+ categories: Category[] | null,
};
type RowProps = {
category: Category,
- maxAbsBalanceToBudgetRatio: number,
};
export default Histogram;
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;