From 6e827308ce332a842c87f19d7adedf827544317e Mon Sep 17 00:00:00 2001 From: Joe Carstairs Date: Thu, 5 Sep 2024 22:44:37 +0100 Subject: Adds GUI with basic histogram --- gui/src/ajax/backend.ts | 5 ++ gui/src/components/app/App.css | 1 + gui/src/components/app/App.tsx | 53 ++++++++++++++++++ gui/src/components/histogram/Histogram.scss | 84 +++++++++++++++++++++++++++++ gui/src/components/histogram/Histogram.tsx | 70 ++++++++++++++++++++++++ gui/src/index.css | 72 +++++++++++++++++++++++++ gui/src/index.tsx | 9 ++++ gui/src/types/account.ts | 11 ++++ gui/src/types/budget.ts | 8 +++ gui/src/types/category.ts | 12 +++++ gui/src/types/currency/currency.ts | 6 +++ gui/src/types/currency/gbp.ts | 46 ++++++++++++++++ gui/src/types/dtos/accountDto.ts | 8 +++ gui/src/vite-env.d.ts | 1 + 14 files changed, 386 insertions(+) create mode 100644 gui/src/ajax/backend.ts create mode 100644 gui/src/components/app/App.css create mode 100644 gui/src/components/app/App.tsx create mode 100644 gui/src/components/histogram/Histogram.scss create mode 100644 gui/src/components/histogram/Histogram.tsx create mode 100644 gui/src/index.css create mode 100644 gui/src/index.tsx create mode 100644 gui/src/types/account.ts create mode 100644 gui/src/types/budget.ts create mode 100644 gui/src/types/category.ts create mode 100644 gui/src/types/currency/currency.ts create mode 100644 gui/src/types/currency/gbp.ts create mode 100644 gui/src/types/dtos/accountDto.ts create mode 100644 gui/src/vite-env.d.ts (limited to 'gui/src') diff --git a/gui/src/ajax/backend.ts b/gui/src/ajax/backend.ts new file mode 100644 index 0000000..d58e513 --- /dev/null +++ b/gui/src/ajax/backend.ts @@ -0,0 +1,5 @@ +const backendUrl = "http://localhost:8000"; + +async function getAllAccounts(): Promise { + return; +} diff --git a/gui/src/components/app/App.css b/gui/src/components/app/App.css new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/gui/src/components/app/App.css @@ -0,0 +1 @@ + diff --git a/gui/src/components/app/App.tsx b/gui/src/components/app/App.tsx new file mode 100644 index 0000000..cd9f9ee --- /dev/null +++ b/gui/src/components/app/App.tsx @@ -0,0 +1,53 @@ +import Category from '../../types/category'; +import Gbp from '../../types/currency/gbp'; +import Histogram from '../histogram/Histogram' +import './App.css' + +function App() { + return ( + <> + + + ) +} + +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.scss b/gui/src/components/histogram/Histogram.scss new file mode 100644 index 0000000..e3f4604 --- /dev/null +++ b/gui/src/components/histogram/Histogram.scss @@ -0,0 +1,84 @@ +table { + border-collapse: collapse; +} + +:is(thead, .ratio__text) { + opacity: 0; + height: 0; + width: 0; +} + +tr { + display: grid; + gap: 0.3rem; + grid-template-columns: 10rem 1fr min-content; + justify-content: start; +} + +td.name { + text-align: right; +} + +td.ratio { + width: 10rem; + display: grid; + grid-template-columns: 5rem 5rem; + grid-template-rows: 1fr; + align-items: center; + + &::before { + content: ''; + display: block; + height: 100%; + grid-row: 1 / 2; + z-index: -1; + } + + &:is(.ratio--save, .ratio--spend)::before { + grid-column: 1 / 2; + border-right: 1px dashed white; + position: relative; + right: -1px; + } + + &.ratio--arrears::before { + grid-column: 2 / 3; + border-left: 1px dashed white; + left: -1px; + } +} + +.ratio__bar { + display: block; + width: calc(3rem * var(--normal-abs-ratio) + 1px); + height: 33%; + background-color: var(--category-highlight-color); + grid-row: 1 / 2; + + :is(.ratio--save, .ratio--spend) & { + grid-column: 2 / 3; + } + + .ratio--arrears & { + grid-column: 1 / 2; + position: relative; + right: -1px; + justify-self: end; + } +} + +td.balance { + color: var(--category-highlight-color); +} + +.ratio--arrears { + --category-highlight-color: crimson; +} + +.ratio--save { + --category-highlight-color: white; +} + +.ratio--spend { + --category-highlight-color: dodgerblue; +} diff --git a/gui/src/components/histogram/Histogram.tsx b/gui/src/components/histogram/Histogram.tsx new file mode 100644 index 0000000..ee4b7b0 --- /dev/null +++ b/gui/src/components/histogram/Histogram.tsx @@ -0,0 +1,70 @@ +import Category from "../../types/category"; +import './Histogram.scss'; + +function Histogram({ categories }: Props) { + const maxAbsBalanceToBudgetRatio = Math.max( + ...categories.map((c) => Math.abs(c.balanceToBudgetRatio)) + ); + + return ( + + + + + + + + + + + { + categories + .sort((c1, c2) => c1.balanceToBudgetRatio - c2.balanceToBudgetRatio) + .map((category) => Row({ category, maxAbsBalanceToBudgetRatio })) + } + +
CategoryBalance-to-Budget RatioBalance
+ ); +} + +function Row({ category, maxAbsBalanceToBudgetRatio }: RowProps) { + const normalisedRatio = category.balanceToBudgetRatio / maxAbsBalanceToBudgetRatio; + const variant = getRowVariant(new Number(normalisedRatio).valueOf()); + + return ( + + {category.name} + + + {category.balanceToBudgetRatio.toFixed(2)} + + + + {category.currentBalance.toString()} + + ); +} + +function getRowVariant(balanceToBudgetRatio: number) { + if (balanceToBudgetRatio < 0) { + return 'arrears'; + } + if (balanceToBudgetRatio >= 1) { + return 'spend'; + } + return 'save'; +} + +type Props = { + categories: Category[], +}; + +type RowProps = { + category: Category, + maxAbsBalanceToBudgetRatio: number, +}; + +export default Histogram; diff --git a/gui/src/index.css b/gui/src/index.css new file mode 100644 index 0000000..075ca0d --- /dev/null +++ b/gui/src/index.css @@ -0,0 +1,72 @@ +:root { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +body { + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} + +* { + margin: 0; + padding: 0; +} diff --git a/gui/src/index.tsx b/gui/src/index.tsx new file mode 100644 index 0000000..eec284c --- /dev/null +++ b/gui/src/index.tsx @@ -0,0 +1,9 @@ +/* @refresh reload */ +import { render } from 'solid-js/web' + +import './index.css' +import App from './components/app/App' + +const root = document.getElementById('root') + +render(() => , root!) diff --git a/gui/src/types/account.ts b/gui/src/types/account.ts new file mode 100644 index 0000000..4a3626c --- /dev/null +++ b/gui/src/types/account.ts @@ -0,0 +1,11 @@ +import { Moment } from "moment"; +import Currency from "./currency/currency"; + +interface Account { + readonly id: number; + readonly name: string; + readonly openingBalance: Currency; + readonly openingDate: Moment; +} + +export default Account; diff --git a/gui/src/types/budget.ts b/gui/src/types/budget.ts new file mode 100644 index 0000000..86bae2c --- /dev/null +++ b/gui/src/types/budget.ts @@ -0,0 +1,8 @@ +import Currency from "./currency/currency"; + +interface Budget { + quantity: Currency, + period: number, +}; + +export default Budget; diff --git a/gui/src/types/category.ts b/gui/src/types/category.ts new file mode 100644 index 0000000..af4526b --- /dev/null +++ b/gui/src/types/category.ts @@ -0,0 +1,12 @@ +import Budget from "./budget"; +import Currency from "./currency/currency"; + +interface Category { + id: number, + name: string, + currentBalance: Currency, + currentBudget: Budget, + balanceToBudgetRatio: number, +}; + +export default Category; diff --git a/gui/src/types/currency/currency.ts b/gui/src/types/currency/currency.ts new file mode 100644 index 0000000..7c15324 --- /dev/null +++ b/gui/src/types/currency/currency.ts @@ -0,0 +1,6 @@ +interface Currency { + valueOf(): number; + toString(): string; +} + +export default Currency; diff --git a/gui/src/types/currency/gbp.ts b/gui/src/types/currency/gbp.ts new file mode 100644 index 0000000..ce24e48 --- /dev/null +++ b/gui/src/types/currency/gbp.ts @@ -0,0 +1,46 @@ +import Currency from "./currency"; + +class Gbp implements Currency { + private readonly sign: 1 | -1; + private readonly pounds: number; + private readonly pence: number; + + constructor(pounds: number, pence: number, sign?: 1 | -1) { + sign = sign ?? 1; + + if (Math.round(pounds) !== pounds) { + throw new Error( + `Cannot construct GBP with non-whole number of pounds: ${pounds}`, + ); + } + if (Math.round(pence) !== pence) { + throw new Error( + `Cannot construct GBP with non-whole number of pence: ${pence}`, + ); + } + if (pounds < 0) { + throw new Error( + `Cannot construct GBP with negative number of pounds: ${pounds}`, + ); + } + if (pence < 0) { + throw new Error( + `Cannot construct GBP with negative number of pounds: ${pounds}`, + ); + } + + this.sign = sign; + this.pounds = pounds; + this.pence = pence; + } + + valueOf(): number { + return this.sign * (this.pounds * 100 + this.pence); + } + + toString(): string { + return `£${this.pounds}·${this.pence < 10 ? `0${this.pence}` : this.pence}`; + } +} + +export default Gbp; diff --git a/gui/src/types/dtos/accountDto.ts b/gui/src/types/dtos/accountDto.ts new file mode 100644 index 0000000..0ed7622 --- /dev/null +++ b/gui/src/types/dtos/accountDto.ts @@ -0,0 +1,8 @@ +interface AccountDto { + readonly id: number; + readonly name: string; + readonly openingBalance: number; + readonly openingDate: string; +} + +export default AccountDto; diff --git a/gui/src/vite-env.d.ts b/gui/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/gui/src/vite-env.d.ts @@ -0,0 +1 @@ +/// -- cgit v1.2.3