summaryrefslogtreecommitdiff
path: root/gui/src
diff options
context:
space:
mode:
authorJoe Carstairs <me@joeac.net>2024-09-05 22:44:37 +0100
committerJoe Carstairs <me@joeac.net>2024-09-05 22:44:37 +0100
commit6e827308ce332a842c87f19d7adedf827544317e (patch)
tree9509622166b56537469ec48bbe980efee75b6974 /gui/src
parent95d4db4da725b74999524a1307e5e17b434dbfeb (diff)
Adds GUI with basic histogram
Diffstat (limited to 'gui/src')
-rw-r--r--gui/src/ajax/backend.ts5
-rw-r--r--gui/src/components/app/App.css1
-rw-r--r--gui/src/components/app/App.tsx53
-rw-r--r--gui/src/components/histogram/Histogram.scss84
-rw-r--r--gui/src/components/histogram/Histogram.tsx70
-rw-r--r--gui/src/index.css72
-rw-r--r--gui/src/index.tsx9
-rw-r--r--gui/src/types/account.ts11
-rw-r--r--gui/src/types/budget.ts8
-rw-r--r--gui/src/types/category.ts12
-rw-r--r--gui/src/types/currency/currency.ts6
-rw-r--r--gui/src/types/currency/gbp.ts46
-rw-r--r--gui/src/types/dtos/accountDto.ts8
-rw-r--r--gui/src/vite-env.d.ts1
14 files changed, 386 insertions, 0 deletions
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<Account[]> {
+ 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 (
+ <>
+ <Histogram categories={categories} />
+ </>
+ )
+}
+
+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 (
+ <table>
+ <thead>
+ <tr>
+ <th>Category</th>
+ <th>Balance-to-Budget Ratio</th>
+ <th>Balance</th>
+ </tr>
+ </thead>
+
+ <tbody>
+ {
+ categories
+ .sort((c1, c2) => c1.balanceToBudgetRatio - c2.balanceToBudgetRatio)
+ .map((category) => Row({ category, maxAbsBalanceToBudgetRatio }))
+ }
+ </tbody>
+ </table>
+ );
+}
+
+function Row({ category, maxAbsBalanceToBudgetRatio }: RowProps) {
+ const normalisedRatio = category.balanceToBudgetRatio / maxAbsBalanceToBudgetRatio;
+ const variant = getRowVariant(new Number(normalisedRatio).valueOf());
+
+ return (
+ <tr>
+ <td class="name">{category.name}</td>
+ <td
+ class={`ratio ratio--${variant}`}
+ style={`--normal-abs-ratio: ${Math.abs(normalisedRatio)};`}
+ >
+ <span class="ratio__text">
+ {category.balanceToBudgetRatio.toFixed(2)}
+ </span>
+ <span class="ratio__bar"></span>
+ </td>
+ <td class="balance">{category.currentBalance.toString()}</td>
+ </tr>
+ );
+}
+
+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(() => <App />, 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 @@
+/// <reference types="vite/client" />