>;
5 | export type FCWithChildren = React.FC>;
6 |
--------------------------------------------------------------------------------
/src/state/app/actions.ts:
--------------------------------------------------------------------------------
1 | import { mapValues } from "lodash";
2 | import React from "react";
3 | import { AppSlice, DefaultPages, getPagePathForPageState } from ".";
4 | import { TopHatDispatch } from "..";
5 | import { PageStateType } from "./pageTypes";
6 |
7 | export const OpenPageCache = mapValues(
8 | DefaultPages,
9 | (page) => (event: React.MouseEvent) => openNewPage(DefaultPages[page.id], event)
10 | );
11 |
12 | export const openNewPage = (state: PageStateType, event: React.MouseEvent) => {
13 | if (event.metaKey) {
14 | window.open(getPagePathForPageState(state), "_blank");
15 | } else {
16 | TopHatDispatch(AppSlice.actions.setPageState(state));
17 | }
18 | };
19 |
--------------------------------------------------------------------------------
/src/state/app/defaults.ts:
--------------------------------------------------------------------------------
1 | import { omit } from "lodash";
2 | import { DefaultTransactionsTableFilters, DefaultTransactionsTableState } from "../../components/table/table/types";
3 | import { Account, Category, Currency, Institution, PLACEHOLDER_CATEGORY_ID, Rule, Statement } from "../data";
4 | import { DialogFileState } from "./statementTypes";
5 |
6 | export const DefaultPages = {
7 | summary: { id: "summary" as const },
8 | accounts: {
9 | id: "accounts" as const,
10 | chartSign: "all" as const,
11 | chartAggregation: "type" as const,
12 | account: [],
13 | institution: [],
14 | currency: [],
15 | type: [],
16 | filterInactive: true,
17 | balances: "all" as const,
18 | },
19 | account: {
20 | id: "account" as const,
21 | account: 0,
22 | table: {
23 | filters: omit(DefaultTransactionsTableFilters, "account"),
24 | state: DefaultTransactionsTableState,
25 | },
26 | },
27 | transactions: {
28 | id: "transactions" as const,
29 | transfers: false,
30 | chartSign: "debits" as const,
31 | chartAggregation: "category" as const,
32 | table: {
33 | filters: DefaultTransactionsTableFilters,
34 | state: DefaultTransactionsTableState,
35 | },
36 | },
37 | categories: {
38 | id: "categories",
39 | tableMetric: "average",
40 | summaryMetric: "current",
41 | tableSign: "debits",
42 | summarySign: "debits",
43 | hideEmpty: "subcategories",
44 | } as const,
45 | category: {
46 | id: "category" as const,
47 | category: PLACEHOLDER_CATEGORY_ID,
48 | table: {
49 | nested: true,
50 | filters: DefaultTransactionsTableFilters,
51 | state: DefaultTransactionsTableState,
52 | },
53 | },
54 | forecasts: { id: "forecasts" as const },
55 | };
56 |
57 | const defaultValues = {
58 | account: undefined as Account | undefined,
59 | institution: undefined as Institution | undefined,
60 | category: undefined as Category | undefined,
61 | currency: undefined as Currency | undefined,
62 | statement: undefined as Statement | undefined,
63 | import: { page: "file", rejections: [] } as DialogFileState,
64 | rule: undefined as Rule | undefined,
65 | settings: undefined as
66 | | "summary"
67 | | "import"
68 | | "export"
69 | | "debug"
70 | | "storage"
71 | | "notifications"
72 | | "currency"
73 | | "history"
74 | | undefined,
75 | };
76 | export const DefaultDialogs = { id: "closed" as "closed" | keyof typeof defaultValues, ...defaultValues };
77 | export type DialogState = typeof DefaultDialogs;
78 |
--------------------------------------------------------------------------------
/src/state/app/hooks.ts:
--------------------------------------------------------------------------------
1 | import { identity } from "lodash";
2 | import { DialogState } from ".";
3 | import { useSelector } from "../shared/hooks";
4 | import {
5 | AccountPageState,
6 | AccountsPageState,
7 | CategoriesPageState,
8 | CategoryPageState,
9 | TransactionsPageState,
10 | } from "./pageTypes";
11 |
12 | export const useAccountsPageState = (
13 | selector: (state: AccountsPageState) => T = identity,
14 | equalityFn?: (left: T, right: T) => boolean
15 | ) => useSelector((state) => selector(state.app.page as AccountsPageState), equalityFn);
16 |
17 | export const useAccountPageState = (
18 | selector: (state: AccountPageState) => T = identity,
19 | equalityFn?: (left: T, right: T) => boolean
20 | ) => useSelector((state) => selector(state.app.page as AccountPageState), equalityFn);
21 | export const useAccountPageAccount = () =>
22 | useSelector((state) => state.data.account.entities[(state.app.page as AccountPageState).account]!);
23 |
24 | export const useTransactionsPageState = (
25 | selector: (state: TransactionsPageState) => T = identity,
26 | equalityFn?: (left: T, right: T) => boolean
27 | ) => useSelector((state) => selector(state.app.page as TransactionsPageState), equalityFn);
28 |
29 | export const useCategoriesPageState = (
30 | selector: (state: CategoriesPageState) => T = identity,
31 | equalityFn?: (left: T, right: T) => boolean
32 | ) => useSelector((state) => selector(state.app.page as CategoriesPageState), equalityFn);
33 |
34 | export const useCategoryPageState = (
35 | selector: (state: CategoryPageState) => T = identity,
36 | equalityFn?: (left: T, right: T) => boolean
37 | ) => useSelector((state) => selector(state.app.page as CategoryPageState), equalityFn);
38 | export const useCategoryPageCategory = () =>
39 | useSelector((state) => state.data.category.entities[(state.app.page as CategoryPageState).category]!);
40 |
41 | export const useDialogPage = () => useSelector((state) => state.app.dialog.id);
42 | type DialogPageID = Exclude;
43 | export const useDialogState = (
44 | id: ID,
45 | callback: (state: DialogState[ID]) => T = identity
46 | ) => useSelector((state) => callback(state.app.dialog[id]));
47 | export const useDialogHasWorking = () => useSelector(({ app: { dialog } }) => !!dialog[dialog.id as DialogPageID]);
48 |
--------------------------------------------------------------------------------
/src/state/app/pageTypes.ts:
--------------------------------------------------------------------------------
1 | import { TransactionsTableFilters, TransactionsTableState } from "../../components/table/table/types";
2 | import { ID } from "../shared/values";
3 |
4 | export type ChartSign = "all" | "credits" | "debits";
5 | export const ChartSigns = ["all", "credits", "debits"] as ChartSign[];
6 | export type BooleanFilter = "all" | "include" | "exclude";
7 | export const BooleanFilters = ["all", "include", "exclude"] as BooleanFilter[];
8 |
9 | export interface SummaryPageState {
10 | id: "summary";
11 | }
12 | export const AccountsPageAggregations = ["account", "currency", "institution", "type"] as const;
13 | export interface AccountsPageState {
14 | // Page ID
15 | id: "accounts";
16 |
17 | // Summary
18 | chartSign: ChartSign;
19 | chartAggregation: typeof AccountsPageAggregations[number];
20 |
21 | // Filters
22 | account: ID[];
23 | institution: ID[];
24 | type: ID[];
25 | currency: ID[];
26 | balances: ChartSign;
27 | filterInactive: boolean;
28 | }
29 | export interface AccountPageState {
30 | id: "account";
31 | account: ID;
32 | table: {
33 | filters: Omit;
34 | state: TransactionsTableState;
35 | };
36 | }
37 |
38 | export const TransactionsPageAggregations = ["category", "currency", "account"] as const;
39 | export interface TransactionsPageState {
40 | // Page ID
41 | id: "transactions";
42 |
43 | // Summary
44 | chartSign: ChartSign;
45 | chartAggregation: typeof TransactionsPageAggregations[number];
46 |
47 | // Table
48 | table: {
49 | filters: TransactionsTableFilters;
50 | state: TransactionsTableState;
51 | };
52 | }
53 | export interface CategoriesPageState {
54 | id: "categories";
55 | summaryMetric: "current" | "previous" | "average";
56 | tableMetric: "current" | "previous" | "average";
57 | hideEmpty: "none" | "subcategories" | "all";
58 | summarySign: ChartSign;
59 | tableSign: ChartSign;
60 | }
61 | export interface CategoryPageState {
62 | id: "category";
63 | category: ID;
64 | table: {
65 | nested: boolean;
66 | filters: TransactionsTableFilters;
67 | state: TransactionsTableState;
68 | };
69 | }
70 | export interface ForecastsPageState {
71 | id: "forecasts";
72 | }
73 |
74 | export type PageStateType =
75 | | SummaryPageState
76 | | AccountsPageState
77 | | AccountPageState
78 | | TransactionsPageState
79 | | CategoriesPageState
80 | | CategoryPageState
81 | | ForecastsPageState;
82 |
--------------------------------------------------------------------------------
/src/state/app/statementTypes.ts:
--------------------------------------------------------------------------------
1 | import { FileRejection } from "react-dropzone";
2 | import { Account } from "../data";
3 | import {
4 | DialogColumnExclusionConfig,
5 | DialogColumnParseResult,
6 | DialogColumnTransferConfig,
7 | DialogColumnValueMapping,
8 | DialogFileDescription,
9 | DialogParseSpecification,
10 | } from "../logic/statement";
11 |
12 | // Screens
13 | interface DialogStatementPageState {
14 | page: Page;
15 | account?: Account;
16 | }
17 | export interface DialogStatementFileState extends DialogStatementPageState<"file"> {
18 | rejections: FileRejection[];
19 | }
20 | export interface DialogStatementParseState extends DialogStatementPageState<"parse"> {
21 | file: string;
22 | parse: DialogParseSpecification;
23 | files: DialogFileDescription[];
24 | columns: DialogColumnParseResult;
25 | }
26 | export interface DialogStatementMappingState extends DialogStatementPageState<"mapping"> {
27 | file: string;
28 | parse: DialogParseSpecification;
29 | files: DialogFileDescription[];
30 | columns: DialogColumnParseResult;
31 | mapping: DialogColumnValueMapping;
32 | }
33 | export interface DialogStatementImportState extends DialogStatementPageState<"import"> {
34 | file: string;
35 | parse: DialogParseSpecification;
36 | files: DialogFileDescription[];
37 | columns: DialogColumnParseResult;
38 | mapping: DialogColumnValueMapping;
39 | exclude: DialogColumnExclusionConfig;
40 | transfers: DialogColumnTransferConfig;
41 | reverse: boolean;
42 | }
43 |
44 | export type DialogFileState =
45 | | DialogStatementFileState
46 | | DialogStatementParseState
47 | | DialogStatementMappingState
48 | | DialogStatementImportState;
49 |
--------------------------------------------------------------------------------
/src/state/data/demo/post.ts:
--------------------------------------------------------------------------------
1 | import { range, rangeRight } from "lodash";
2 | import { DataState } from "..";
3 | import { DEMO_NOTIFICATION_ID } from "../../logic/notifications/types";
4 | import { getTodayString } from "../../shared/values";
5 |
6 | const start = getTodayString();
7 | export const finishDemoInitialisation = (state: DataState, download: string) => {
8 | // Travel budget
9 | const travelCategory = state.category.entities[4]!;
10 | const travelBudget = -250;
11 | const travelBudgetHistory: number[] = [];
12 | rangeRight(24).forEach((idx) => {
13 | const previous = travelCategory.transactions.debits[idx + 1] || 0;
14 | const budget = travelBudget + (travelBudgetHistory[0] || 0) - previous;
15 | travelBudgetHistory[0] = previous;
16 | travelBudgetHistory.unshift(budget);
17 | });
18 | travelCategory.budgets = { start, values: travelBudgetHistory, strategy: "rollover", base: travelBudget };
19 |
20 | // Income budget
21 | const incomeCategory = state.category.entities[6]!;
22 | incomeCategory.budgets = {
23 | start,
24 | values: range(24).map(
25 | (i) => (incomeCategory.transactions.credits[i] || incomeCategory.transactions.credits[1]) - 10
26 | ),
27 | strategy: "copy",
28 | base: 0,
29 | };
30 |
31 | // This leads to too many notifications on startup
32 | // state.account.ids.forEach((id) => {
33 | // const account = state.account.entities[id]!;
34 | // account.lastUpdate = account.lastTransactionDate || getTodayString();
35 | // if (account.openDate > account.lastUpdate) account.openDate = account.lastUpdate;
36 | // });
37 |
38 | state.notification.ids = [DEMO_NOTIFICATION_ID].concat(state.notification.ids as string[]);
39 | state.notification.entities[DEMO_NOTIFICATION_ID] = {
40 | id: DEMO_NOTIFICATION_ID,
41 | contents: download,
42 | };
43 |
44 | // Add some recordedBalances to demo
45 | state.transaction.ids.forEach((id) => {
46 | const tx = state.transaction.entities[id]!;
47 |
48 | // Transaction Account
49 | if (tx.account === 6) tx.recordedBalance = tx.balance;
50 | });
51 | };
52 |
--------------------------------------------------------------------------------
/src/state/data/index.test.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @vitest-environment jsdom
3 | */
4 |
5 | import produce from "immer";
6 | import { cloneDeep, keys, mapValues, sortBy } from "lodash";
7 | import { expect, test } from "vitest";
8 | import { DataSlice, DataState, refreshCaches } from ".";
9 | import { TopHatDispatch, TopHatStore } from "..";
10 | import { ID } from "../shared/values";
11 | import { DemoData } from "./demo/data";
12 | import { DataKeys, StubUserID } from "./types";
13 |
14 | test("State remains valid during transformations", () => {
15 | TopHatDispatch(DataSlice.actions.setUpDemo(DemoData));
16 | const { data } = TopHatStore.getState();
17 | validateStateIntegrity(data);
18 |
19 | // Update transaction
20 | TopHatDispatch(
21 | DataSlice.actions.updateTransactions([
22 | { id: data.transaction.ids[0], changes: { account: data.account.ids[0] as ID } },
23 | { id: data.transaction.ids[1], changes: { currency: data.account.ids[1] as ID } },
24 | { id: data.transaction.ids[2], changes: { currency: data.currency.ids[0] as ID } },
25 | { id: data.transaction.ids[3], changes: { currency: data.currency.ids[1] as ID } },
26 | ])
27 | );
28 | validateStateIntegrity(TopHatStore.getState().data);
29 |
30 | // Update transactions
31 | const currency = cloneDeep(data.currency.entities[data.user.entities[StubUserID]!.currency]!);
32 | currency.rates[0].value = 10;
33 | TopHatDispatch(DataSlice.actions.updateCurrencyRates([currency]));
34 | validateStateIntegrity(TopHatStore.getState().data);
35 | });
36 |
37 | const validateStateIntegrity = (state: DataState) => {
38 | // Check valid entity states
39 | expect(
40 | DataKeys.map((key) => [
41 | key,
42 | sortBy(
43 | state[key].ids.map((x) => "" + x),
44 | (x) => x
45 | ),
46 | ])
47 | ).toEqual(
48 | DataKeys.map((key) => [
49 | key,
50 | sortBy(
51 | keys(state[key].entities).map((x) => "" + x),
52 | (x) => x
53 | ),
54 | ])
55 | );
56 |
57 | // Check user IDs are correct
58 | expect(state.user.ids).toEqual([StubUserID]);
59 |
60 | // Check caches
61 | const refreshed = produce(state, (draft) => void refreshCaches(draft));
62 | expect(truncateForTesting(state)).toEqual(truncateForTesting(refreshed));
63 | };
64 |
65 | const truncateForTesting = (value: T): T => {
66 | if (!value) return value;
67 | if (Array.isArray(value)) return value.map(truncateForTesting) as unknown as T;
68 |
69 | switch (typeof value) {
70 | case "object":
71 | return mapValues(value as any as object, truncateForTesting) as unknown as T;
72 | case "number":
73 | return (Math.round(value * 100) / 100) as unknown as T;
74 | default:
75 | return value;
76 | }
77 | };
78 |
--------------------------------------------------------------------------------
/src/state/index.ts:
--------------------------------------------------------------------------------
1 | import { configureStore } from "@reduxjs/toolkit";
2 | import { AppSlice } from "./app";
3 | import { DataSlice } from "./data";
4 |
5 | export const TopHatStore = configureStore({
6 | reducer: {
7 | app: AppSlice.reducer,
8 | data: DataSlice.reducer,
9 | },
10 | });
11 |
12 | export type TopHatState = ReturnType;
13 | export const TopHatDispatch = TopHatStore.dispatch;
14 |
--------------------------------------------------------------------------------
/src/state/logic/database.ts:
--------------------------------------------------------------------------------
1 | import Dexie, { DexieOptions } from "dexie";
2 | import "dexie-observable";
3 | import {
4 | Account,
5 | Category,
6 | Currency,
7 | Institution,
8 | Notification,
9 | PatchGroup,
10 | Rule,
11 | Statement,
12 | Transaction,
13 | User,
14 | } from "../data/types";
15 | import { ID } from "../shared/values";
16 |
17 | // This class is so Typescript understands the shape of the DB connection
18 | export class TopHatDexie extends Dexie {
19 | user: Dexie.Table;
20 | account: Dexie.Table;
21 | category: Dexie.Table;
22 | currency: Dexie.Table;
23 | institution: Dexie.Table;
24 | rule: Dexie.Table;
25 | transaction_: Dexie.Table; // "transaction" conflicts with Dexie-internal property
26 | statement: Dexie.Table;
27 | notification: Dexie.Table;
28 | patches: Dexie.Table;
29 |
30 | constructor(options?: DexieOptions) {
31 | super("TopHatDatabase", options);
32 | this.version(2).stores({
33 | user: "id",
34 | account: "id",
35 | category: "id",
36 | currency: "id",
37 | institution: "id",
38 | rule: "id",
39 | transaction_: "id, statement",
40 | statement: "id",
41 | notification: "id",
42 | patches: "id",
43 | });
44 |
45 | // This is for compatibility with babel-preset-typescript
46 | this.user = this.table("user");
47 | this.account = this.table("account");
48 | this.category = this.table("category");
49 | this.currency = this.table("currency");
50 | this.institution = this.table("institution");
51 | this.rule = this.table("rule");
52 | this.transaction_ = this.table("transaction_");
53 | this.statement = this.table("statement");
54 | this.notification = this.table("notification");
55 | this.patches = this.table("patches");
56 |
57 | // This would enable functions on objects - it would require classes rather than interfaces
58 | // this.user.mapToClass(UserState);
59 | }
60 | }
61 |
62 | // const DBUpdateTypes = ["DEMO"] as const;
63 | // export type DBUpdateType = typeof DBUpdateTypes[number];
64 |
65 | // export const attachIDBChangeHandler = (
66 | // db: TopHatDexie,
67 | // callback: (changes: IDatabaseChange[]) => void
68 | // // exclusions?: DBUpdateType[]
69 | // ) => {
70 | // let running: IDatabaseChange[] = [];
71 | // db.on("changes", (changes, partial) => {
72 | // // Dexie breaks up large changes - this combines them again so we don't operate on inconsistent states
73 | // if (partial) {
74 | // running = running.concat(changes);
75 | // return;
76 | // } else {
77 | // changes = running.concat(changes);
78 | // running = [];
79 | // }
80 |
81 | // // if (exclusions) changes = changes.filter((change) => !(exclusions as string[]).includes(change.source!));
82 | // if (changes.length !== 0) callback(changes);
83 | // });
84 | // };
85 |
--------------------------------------------------------------------------------
/src/state/logic/import.ts:
--------------------------------------------------------------------------------
1 | import { batch } from "react-redux";
2 | import { TopHatDispatch } from "../../state";
3 | import { DataSlice, DataState } from "../../state/data";
4 | import { updateSyncedCurrencies } from "../../state/logic/currencies";
5 | import { StubUserID } from "../data/types";
6 | import { handleMigrationsAndUpdates } from "./startup";
7 |
8 | export const importJSONData = (file: string) =>
9 | batch(() => {
10 | const data = JSON.parse(file) as DataState;
11 | TopHatDispatch(DataSlice.actions.setFromJSON(data));
12 | handleMigrationsAndUpdates(data.user.entities[StubUserID]?.generation);
13 | TopHatDispatch(DataSlice.actions.updateTransactionSummaryStartDates());
14 |
15 | updateSyncedCurrencies(); // Not awaited
16 | });
17 |
--------------------------------------------------------------------------------
/src/state/logic/notifications/index.tsx:
--------------------------------------------------------------------------------
1 | import { zipObject } from "../../../shared/data";
2 | import { subscribeToDataUpdates } from "../../data";
3 | import { Notification } from "../../data/types";
4 | import { AccountNotificationDefinition } from "./variants/accounts";
5 | import { CurrencyNotificationDefinition } from "./variants/currency";
6 | import { DebtNotificationDefinition } from "./variants/debt";
7 | import { DemoNotificationDefinition } from "./variants/demo";
8 | import { DropboxNotificationDefinition } from "./variants/dropbox";
9 | import { IDBNotificationDefinition } from "./variants/idb";
10 | import { MilestoneNotificationDefinition } from "./variants/milestone";
11 | import { UncategorisedNotificationDefinition } from "./variants/uncategorised";
12 | export type { NotificationDisplayMetadata } from "./types";
13 |
14 | const rules = [
15 | DemoNotificationDefinition,
16 | IDBNotificationDefinition,
17 | DebtNotificationDefinition,
18 | AccountNotificationDefinition,
19 | MilestoneNotificationDefinition,
20 | UncategorisedNotificationDefinition,
21 | CurrencyNotificationDefinition,
22 | DropboxNotificationDefinition,
23 | ] as const;
24 |
25 | const definitions = zipObject(
26 | rules.map((rule) => rule.id),
27 | rules
28 | );
29 | export const getNotificationDisplayMetadata = (notification: Notification) =>
30 | definitions[notification.id].display(notification);
31 |
32 | export const initialiseNotificationUpdateHook = () =>
33 | subscribeToDataUpdates((previous, current) =>
34 | rules.forEach((rule) => rule.maybeUpdateState && rule.maybeUpdateState(previous, current))
35 | );
36 |
--------------------------------------------------------------------------------
/src/state/logic/notifications/shared.tsx:
--------------------------------------------------------------------------------
1 | import { styled, Typography } from "@mui/material";
2 | import { TopHatDispatch } from "../..";
3 | import { FCWithChildren } from "../../../shared/types";
4 | import { Intents } from "../../../styles/colours";
5 | import { DataSlice } from "../../data";
6 |
7 | export const DefaultDismissNotificationThunk = (id: string) => () =>
8 | TopHatDispatch(DataSlice.actions.deleteNotification(id));
9 |
10 | export const GreenNotificationText = styled("strong")({ color: Intents.success.main });
11 | export const OrangeNotificationText = styled("strong")({ color: Intents.warning.main });
12 | export const NotificationContents: FCWithChildren = ({ children }) => (
13 |
14 | {children}
15 |
16 | );
17 |
--------------------------------------------------------------------------------
/src/state/logic/notifications/types.ts:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { IconType } from "../../../shared/types";
3 | import { DataState } from "../../data";
4 |
5 | export interface NotificationDisplayMetadata {
6 | icon: IconType;
7 | title: string;
8 | dismiss?: (programatically: boolean) => void;
9 | colour: string;
10 | buttons?: {
11 | text: string;
12 | onClick: (close: () => void) => void;
13 | }[];
14 | children: React.ReactNode;
15 | }
16 |
17 | export interface NotificationRuleDefinition {
18 | id: string;
19 | display: (alert: { id: string; contents: string }) => NotificationDisplayMetadata;
20 | maybeUpdateState?: (previous: DataState | undefined, current: DataState) => void;
21 | }
22 |
23 | export const DEMO_NOTIFICATION_ID = "demo";
24 | export const ACCOUNTS_NOTIFICATION_ID = "old-accounts";
25 | export const CURRENCY_NOTIFICATION_ID = "currency-sync-broken";
26 | export const DEBT_NOTIFICATION_ID = "debt-level";
27 | export const DROPBOX_NOTIFICATION_ID = "dropbox-sync-broken";
28 | export const IDB_NOTIFICATION_ID = "idb-sync-failed";
29 | export const MILESTONE_NOTIFICATION_ID = "new-milestone";
30 | export const UNCATEGORISED_NOTIFICATION_ID = "uncategorised-transactions";
31 |
--------------------------------------------------------------------------------
/src/state/logic/notifications/variants/currency.tsx:
--------------------------------------------------------------------------------
1 | import { CloudOff } from "@mui/icons-material";
2 | import { TopHatDispatch } from "../../..";
3 | import { Intents } from "../../../../styles/colours";
4 | import { AppSlice } from "../../../app";
5 | import { NotificationContents } from "../shared";
6 | import { CURRENCY_NOTIFICATION_ID, NotificationRuleDefinition } from "../types";
7 |
8 | export const CurrencyNotificationDefinition: NotificationRuleDefinition = {
9 | id: CURRENCY_NOTIFICATION_ID,
10 | display: () => ({
11 | icon: CloudOff,
12 | title: "Currency Sync Failed",
13 | colour: Intents.danger.main,
14 | buttons: [{ text: "Manage Config", onClick: goToSyncConfig }],
15 | children: (
16 |
17 | Currency syncs with AlphaVantage are failing - you may need to change the token you're using to pull the
18 | data.
19 |
20 | ),
21 | }),
22 | };
23 |
24 | const goToSyncConfig = () =>
25 | TopHatDispatch(AppSlice.actions.setDialogPartial({ id: "settings", settings: "currency" }));
26 |
--------------------------------------------------------------------------------
/src/state/logic/notifications/variants/debt.tsx:
--------------------------------------------------------------------------------
1 | import { TrendingDown } from "@mui/icons-material";
2 | import { isEqual, sum, values } from "lodash";
3 | import { Intents } from "../../../../styles/colours";
4 | import { DataState, ensureNotificationExists, removeNotification, updateUserData } from "../../../data";
5 | import { useFormatValue } from "../../../data/hooks";
6 | import { StubUserID } from "../../../data/types";
7 | import { DefaultDismissNotificationThunk, GreenNotificationText, NotificationContents } from "../shared";
8 | import { DEBT_NOTIFICATION_ID, NotificationRuleDefinition } from "../types";
9 |
10 | const update = (data: DataState) => {
11 | const user = data.user.entities[StubUserID]!;
12 |
13 | // Balance milestones
14 | const debt = -sum(
15 | values(data.account.entities)
16 | .flatMap((account) => values(account!.balances))
17 | .map((balance) => balance.localised[0])
18 | .filter((value) => value < 0)
19 | );
20 |
21 | // If there is no debt and previous milestone existed, send notification
22 | if (debt <= 0) {
23 | if (user.debt > 0) {
24 | ensureNotificationExists(data, DEBT_NOTIFICATION_ID, "0");
25 | updateUserData(data, { debt: 0 });
26 | } else {
27 | removeNotification(data, DEBT_NOTIFICATION_ID);
28 | updateUserData(data, { debt: 0 });
29 | }
30 | return;
31 | }
32 |
33 | let milestone = Math.pow(10, Math.ceil(Math.log10(debt)));
34 | if (debt <= milestone / 5) milestone /= 5;
35 | else if (debt <= milestone / 2) milestone /= 2;
36 |
37 | // If debt has shrunk, send alert
38 | if (milestone < user.debt && milestone >= 1000) {
39 | ensureNotificationExists(data, DEBT_NOTIFICATION_ID, "" + milestone);
40 | updateUserData(data, { debt: milestone });
41 | return;
42 | }
43 |
44 | // If debt has increased, remove alert and update milestone
45 | if (milestone > user.debt) {
46 | removeNotification(data, DEBT_NOTIFICATION_ID);
47 | updateUserData(data, { debt: milestone });
48 | return;
49 | }
50 | };
51 |
52 | export const DebtNotificationDefinition: NotificationRuleDefinition = {
53 | id: DEBT_NOTIFICATION_ID,
54 | display: (alert) => ({
55 | icon: TrendingDown,
56 | title: alert.contents === "0" ? "Debt Fully Paid!" : "Debt Shrinking!",
57 | dismiss: DefaultDismissNotificationThunk(alert.id),
58 | colour: Intents.success.main,
59 | children: ,
60 | }),
61 | maybeUpdateState: (previous, current) => {
62 | if (
63 | !isEqual(previous?.account, current.account) ||
64 | !isEqual(previous?.user.entities[StubUserID]!.debt, current.user.entities[StubUserID]!.debt)
65 | )
66 | update(current);
67 | },
68 | };
69 |
70 | const DebtMilestoneContents: React.FC<{ value: number }> = ({ value }) => {
71 | const format = useFormatValue({ separator: "", end: "k" });
72 | return value === 0 ? (
73 |
74 | You have paid down all of your debts, across every account. Congratulations!
75 |
76 | ) : (
77 |
78 | You have paid down your debts to under {format(value)}. Keep
79 | up the good work!
80 |
81 | );
82 | };
83 |
--------------------------------------------------------------------------------
/src/state/logic/notifications/variants/demo.tsx:
--------------------------------------------------------------------------------
1 | import { ListAlt } from "@mui/icons-material";
2 | import { TopHatDispatch } from "../../..";
3 | import { createAndDownloadFile } from "../../../../shared/data";
4 | import { Intents } from "../../../../styles/colours";
5 | import { AppSlice } from "../../../app";
6 | import { Statement } from "../../../data";
7 | import { NotificationContents } from "../shared";
8 | import { DEMO_NOTIFICATION_ID, NotificationRuleDefinition } from "../types";
9 |
10 | export const DemoNotificationDefinition: NotificationRuleDefinition = {
11 | id: DEMO_NOTIFICATION_ID,
12 | display: ({ contents: file }) => ({
13 | icon: ListAlt,
14 | title: "Demo Data",
15 | colour: Intents.primary.main,
16 | buttons: [
17 | { text: "Example Statement", onClick: downloadExampleStatement(JSON.parse(file) as Statement) },
18 | { text: "Manage Data", onClick: manageData },
19 | ],
20 | children: (
21 |
22 | TopHat is showing example data. Once you're ready, reset everything to use your own.
23 |
24 | ),
25 | }),
26 | };
27 |
28 | const manageData = () => {
29 | TopHatDispatch(
30 | AppSlice.actions.setDialogPartial({
31 | id: "settings",
32 | settings: "import",
33 | })
34 | );
35 | };
36 |
37 | const downloadExampleStatement = (statement: Statement) => () =>
38 | createAndDownloadFile(statement.name, statement.contents);
39 |
--------------------------------------------------------------------------------
/src/state/logic/notifications/variants/dropbox.tsx:
--------------------------------------------------------------------------------
1 | import { CloudOff } from "@mui/icons-material";
2 | import { TopHatDispatch } from "../../..";
3 | import { Intents } from "../../../../styles/colours";
4 | import { AppSlice } from "../../../app";
5 | import { NotificationContents } from "../shared";
6 | import { DROPBOX_NOTIFICATION_ID, NotificationRuleDefinition } from "../types";
7 |
8 | export const DropboxNotificationDefinition: NotificationRuleDefinition = {
9 | id: DROPBOX_NOTIFICATION_ID,
10 | display: () => ({
11 | icon: CloudOff,
12 | title: "Dropbox Sync Failed",
13 | colour: Intents.danger.main,
14 | buttons: [{ text: "Manage Config", onClick: goToSyncConfig }],
15 | children: (
16 |
17 | Data syncs with Dropbox are failing - you may need to remove the link to Dropbox and re-create it.
18 |
19 | ),
20 | }),
21 | };
22 |
23 | const goToSyncConfig = () => TopHatDispatch(AppSlice.actions.setDialogPartial({ id: "settings", settings: "storage" }));
24 |
--------------------------------------------------------------------------------
/src/state/logic/notifications/variants/idb.tsx:
--------------------------------------------------------------------------------
1 | import { FileDownloadOff } from "@mui/icons-material";
2 | import { Intents } from "../../../../styles/colours";
3 | import { ensureNotificationExists, removeNotification } from "../../../data";
4 | import { NotificationContents } from "../shared";
5 | import { IDB_NOTIFICATION_ID, NotificationRuleDefinition } from "../types";
6 |
7 | let iDBConnectionExists = false;
8 | export const setIDBConnectionExists = (value: boolean) => (iDBConnectionExists = value);
9 |
10 | export const IDBNotificationDefinition: NotificationRuleDefinition = {
11 | id: IDB_NOTIFICATION_ID,
12 | display: () => ({
13 | icon: FileDownloadOff,
14 | title: "Data Save Failed",
15 | colour: Intents.danger.main,
16 | children: (
17 |
18 | TopHat has not been able to connect to the data store, perhaps because it is running in Private Browsing
19 | mode. Data will not be saved.
20 |
21 | ),
22 | }),
23 | maybeUpdateState: (_, current) => {
24 | if (iDBConnectionExists) removeNotification(current, IDB_NOTIFICATION_ID);
25 | else ensureNotificationExists(current, IDB_NOTIFICATION_ID, "");
26 | },
27 | };
28 |
--------------------------------------------------------------------------------
/src/state/logic/notifications/variants/milestone.tsx:
--------------------------------------------------------------------------------
1 | import { TrendingUp } from "@mui/icons-material";
2 | import { isEqual, sum, values } from "lodash";
3 | import { Intents } from "../../../../styles/colours";
4 | import { DataState, ensureNotificationExists, removeNotification, updateUserData } from "../../../data";
5 | import { useFormatValue } from "../../../data/hooks";
6 | import { StubUserID } from "../../../data/types";
7 | import { DefaultDismissNotificationThunk, GreenNotificationText, NotificationContents } from "../shared";
8 | import { MILESTONE_NOTIFICATION_ID, NotificationRuleDefinition } from "../types";
9 |
10 | const update = (data: DataState) => {
11 | const user = data.user.entities[StubUserID]!;
12 |
13 | // Balance milestones
14 | const balance = sum(
15 | values(data.account.entities)
16 | .flatMap((account) => values(account!.balances))
17 | .map((balance) => balance.localised[0])
18 | );
19 |
20 | if (balance <= 0) {
21 | removeNotification(data, MILESTONE_NOTIFICATION_ID);
22 | updateUserData(data, { milestone: 0 });
23 | return;
24 | }
25 |
26 | const milestone = getMilestone(balance);
27 |
28 | if (milestone > user.milestone && milestone >= 10000) {
29 | ensureNotificationExists(data, MILESTONE_NOTIFICATION_ID, "" + milestone);
30 | updateUserData(data, { milestone });
31 | } else if (milestone < user.milestone) {
32 | removeNotification(data, MILESTONE_NOTIFICATION_ID);
33 | updateUserData(data, { milestone });
34 | }
35 | };
36 |
37 | export const MilestoneNotificationDefinition: NotificationRuleDefinition = {
38 | id: MILESTONE_NOTIFICATION_ID,
39 | display: (alert) => ({
40 | icon: TrendingUp,
41 | title: "New Milestone Reached!",
42 | dismiss: DefaultDismissNotificationThunk(alert.id),
43 | colour: Intents.success.main,
44 | children: ,
45 | }),
46 | maybeUpdateState: (previous, current) => {
47 | if (
48 | !isEqual(previous?.account, current.account) ||
49 | !isEqual(previous?.user.entities[StubUserID]!.currency, current.user.entities[StubUserID]!.currency) ||
50 | !isEqual(previous?.user.entities[StubUserID]!.milestone, current.user.entities[StubUserID]!.milestone)
51 | )
52 | update(current);
53 | },
54 | };
55 |
56 | const NewMilestoneContents: React.FC<{ value: number }> = ({ value }) => {
57 | const format = useFormatValue({ separator: "", end: "k" });
58 | return (
59 |
60 | You have a net worth of over {format(value)}, and more every
61 | day. Keep up the good work!
62 |
63 | );
64 | };
65 |
66 | const getMilestone = (balance: number) => {
67 | const magnitude = Math.pow(10, Math.floor(Math.log10(balance)));
68 | const leading = Math.floor(balance / magnitude);
69 | const step = (leading >= 5 ? 0.5 : leading >= 3 ? 0.2 : 0.1) * magnitude;
70 | return Math.floor(balance / step) * step;
71 | };
72 |
--------------------------------------------------------------------------------
/src/state/logic/notifications/variants/uncategorised.tsx:
--------------------------------------------------------------------------------
1 | import { Payment } from "@mui/icons-material";
2 | import { isEqual } from "lodash";
3 | import { TopHatDispatch } from "../../..";
4 | import { Intents } from "../../../../styles/colours";
5 | import { AppSlice, DefaultPages } from "../../../app";
6 | import {
7 | DataState,
8 | PLACEHOLDER_CATEGORY_ID,
9 | ensureNotificationExists,
10 | removeNotification,
11 | updateUserData,
12 | } from "../../../data";
13 | import { StubUserID } from "../../../data/types";
14 | import { DefaultDismissNotificationThunk, NotificationContents, OrangeNotificationText } from "../shared";
15 | import { NotificationRuleDefinition, UNCATEGORISED_NOTIFICATION_ID } from "../types";
16 |
17 | const update = (data: DataState) => {
18 | const { uncategorisedTransactionsAlerted } = data.user.entities[StubUserID]!;
19 |
20 | const uncategorised = data.transaction.ids.filter((id) => {
21 | const tx = data.transaction.entities[id]!;
22 | return tx.category === PLACEHOLDER_CATEGORY_ID && tx.value;
23 | }).length;
24 |
25 | const notification = data.notification.entities[UNCATEGORISED_NOTIFICATION_ID];
26 |
27 | if (uncategorised === 0) {
28 | updateUserData(data, { uncategorisedTransactionsAlerted: false });
29 | removeNotification(data, UNCATEGORISED_NOTIFICATION_ID);
30 | } else if (!uncategorisedTransactionsAlerted || notification) {
31 | updateUserData(data, { uncategorisedTransactionsAlerted: true });
32 | ensureNotificationExists(data, UNCATEGORISED_NOTIFICATION_ID, "" + uncategorised);
33 | }
34 | };
35 |
36 | export const UncategorisedNotificationDefinition: NotificationRuleDefinition = {
37 | id: UNCATEGORISED_NOTIFICATION_ID,
38 | display: (alert) => ({
39 | icon: Payment,
40 | title: "Uncategorised Transactions",
41 | dismiss: DefaultDismissNotificationThunk(alert.id),
42 | colour: Intents.warning.main,
43 | buttons: [{ text: "View Transactions", onClick: viewUncategorisedTransactions }],
44 | children: (
45 |
46 | There are {alert.contents} transactions which haven’t
47 | been allocated to categories.
48 |
49 | ),
50 | }),
51 | maybeUpdateState: (previous, current) => {
52 | if (!isEqual(previous?.category, current.category)) update(current);
53 | },
54 | };
55 |
56 | const viewUncategorisedTransactions = () => {
57 | TopHatDispatch(
58 | AppSlice.actions.setPageState({
59 | ...DefaultPages.transactions,
60 | table: {
61 | filters: {
62 | ...DefaultPages.transactions.table.filters,
63 | category: [PLACEHOLDER_CATEGORY_ID],
64 | },
65 | state: DefaultPages.transactions.table.state,
66 | },
67 | })
68 | );
69 | };
70 |
--------------------------------------------------------------------------------
/src/state/logic/statement/index.ts:
--------------------------------------------------------------------------------
1 | import { FileRejection } from "react-dropzone";
2 | import { TopHatDispatch, TopHatStore } from "../..";
3 | import { AppSlice } from "../../app";
4 | import { DefaultDialogs } from "../../app/defaults";
5 | import { DialogStatementFileState } from "../../app/statementTypes";
6 | import { addStatementFilesToDialog } from "./actions";
7 | import { DialogFileDescription } from "./types";
8 | import { StubUserID } from "../../data/types";
9 | import { importJSONData } from "../import";
10 |
11 | export * from "./actions";
12 | export * from "./types";
13 |
14 | export const handleStatementFileUpload = (rawFiles: File[], rejections: FileRejection[]) => {
15 | const storeState = TopHatStore.getState();
16 | const { import: state, id } = storeState.app.dialog;
17 | const isTutorial = storeState.data.user.entities[StubUserID]?.tutorial;
18 | const { account } = state as DialogStatementFileState;
19 |
20 | if (rejections.length) {
21 | if (rejections.length === 1 && rejections[0].file.name.endsWith(".json") && isTutorial) {
22 | getFilesContents([rejections[0].file]).then((files) => importJSONData(files[0].contents));
23 | return;
24 | }
25 |
26 | TopHatDispatch(
27 | AppSlice.actions.setDialogPartial({
28 | id: "import",
29 | import: { page: "file", account, rejections },
30 | })
31 | );
32 | } else if (rawFiles.length) {
33 | if (id !== "import" || state.page !== "parse")
34 | TopHatDispatch(
35 | AppSlice.actions.setDialogPartial({
36 | id: "import",
37 | import: {
38 | account: state.account,
39 | ...DefaultDialogs.import,
40 | },
41 | })
42 | );
43 |
44 | getFilesContents(rawFiles).then((files) => addStatementFilesToDialog(files));
45 | }
46 | };
47 |
48 | let id = 0;
49 | export const getFilesContents = (files: File[]) =>
50 | Promise.all(
51 | files.map(
52 | (file) =>
53 | new Promise((resolve, reject) => {
54 | const fileReader = new FileReader();
55 | fileReader.onload = (event) => {
56 | id++;
57 |
58 | event.target
59 | ? resolve({
60 | id: id + "",
61 | name: file.name,
62 | contents: event.target.result as string,
63 | })
64 | : reject();
65 | };
66 | fileReader.onerror = reject;
67 | fileReader.readAsText(file);
68 | })
69 | )
70 | );
71 |
--------------------------------------------------------------------------------
/src/state/logic/statement/types.ts:
--------------------------------------------------------------------------------
1 | import { Transaction } from "../../data/types";
2 | import { ID } from "../../shared/values";
3 |
4 | type FILE_ID = string; // Automatically generated
5 | type COLUMN_ID = string; // Automatically generated, unique within a file
6 | type ROW_ID = number; // Index in list of parsed transactions
7 |
8 | export interface DialogParseSpecification {
9 | header: boolean;
10 | delimiter?: string;
11 | dateFormat?: string;
12 | }
13 | export interface DialogFileDescription {
14 | id: FILE_ID;
15 | name: string;
16 | contents: string;
17 | }
18 |
19 | export interface TypedColumn {
20 | id: COLUMN_ID;
21 | name: string;
22 | type: TypeName;
23 | nullable: Nullable;
24 | raw: (string | (Nullable extends true ? null : never))[];
25 | values: (Type | (Nullable extends true ? null : never))[];
26 | }
27 | export type StringColumn = TypedColumn<"string", string, Nullable>;
28 | export type NumberColumn = TypedColumn<"number", number, Nullable>;
29 | export type DateColumn = TypedColumn<"date", string, Nullable>;
30 | export type ColumnProperties =
31 | | StringColumn
32 | | NumberColumn
33 | | DateColumn
34 | | StringColumn
35 | | NumberColumn
36 | | DateColumn;
37 |
38 | export interface DialogColumnParseResult {
39 | all: Record<
40 | FILE_ID,
41 | {
42 | file: FILE_ID;
43 | matches: boolean;
44 | columns?: ColumnProperties[];
45 | }
46 | >;
47 | common:
48 | | { id: COLUMN_ID; name: string; type: "string" | "number" | "date"; nullable: boolean }[]
49 | | (Nullable extends true ? undefined : never);
50 | }
51 |
52 | export interface DialogColumnCurrencyConstantMapping {
53 | type: "constant";
54 | currency: ID;
55 | }
56 | export interface DialogColumnCurrencyColumnMapping {
57 | type: "column";
58 | column: COLUMN_ID;
59 | field: "name" | "ticker" | "symbol";
60 | }
61 | export interface DialogColumnValueMapping {
62 | date: COLUMN_ID;
63 | reference?: COLUMN_ID;
64 | longReference?: COLUMN_ID;
65 | balance?: COLUMN_ID;
66 | value:
67 | | {
68 | type: "value";
69 | value?: COLUMN_ID;
70 | flip: boolean;
71 | }
72 | | {
73 | type: "split";
74 | credit?: COLUMN_ID;
75 | debit?: COLUMN_ID;
76 | flip: boolean;
77 | };
78 | currency: DialogColumnCurrencyConstantMapping | DialogColumnCurrencyColumnMapping;
79 | }
80 |
81 | export type DialogColumnExclusionConfig = Record;
82 | export type DialogColumnTransferConfig = Record<
83 | FILE_ID,
84 | Record
85 | >;
86 |
--------------------------------------------------------------------------------
/src/state/shared/dailycache.ts:
--------------------------------------------------------------------------------
1 | import { SDate, getTodayString } from "./values";
2 |
3 | interface DailyCacheRecord {
4 | date: SDate;
5 | values: {
6 | [key: string]: T;
7 | };
8 | }
9 |
10 | export class DailyCache {
11 | private id: string;
12 |
13 | constructor(id: string) {
14 | this.id = id;
15 | }
16 |
17 | public get(key: string): T | undefined {
18 | return this.getCacheRecord().values[key];
19 | }
20 |
21 | public set(key: string, value: T): void {
22 | const record = this.getCacheRecord();
23 | record.values[key] = value;
24 | localStorage.setItem(this.id, JSON.stringify(record));
25 | }
26 |
27 | private getCacheRecord(): DailyCacheRecord {
28 | const recordString = localStorage.getItem(this.id);
29 | if (!recordString) {
30 | const record = { date: getTodayString(), values: {} };
31 | localStorage.setItem(this.id, JSON.stringify(record));
32 | return record;
33 | }
34 |
35 | let record = JSON.parse(recordString) as DailyCacheRecord;
36 | if (record.date !== getTodayString()) {
37 | record = { date: getTodayString(), values: {} };
38 | localStorage.setItem(this.id, JSON.stringify(record));
39 | }
40 |
41 | return record;
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/state/shared/hooks.ts:
--------------------------------------------------------------------------------
1 | import { useCallback } from "react";
2 | import { TypedUseSelectorHook, useSelector as useSelectorRaw } from "react-redux";
3 | import { TopHatState } from "..";
4 | import { changeCurrencyValue } from "../data";
5 | import { useCurrencyMap, useDefaultCurrency } from "../data/hooks";
6 | import { ID, SDate } from "./values";
7 |
8 | export const useSelector: TypedUseSelectorHook = useSelectorRaw;
9 |
10 | export const useLocaliseCurrencies = () => {
11 | const userDefaultCurrency = useDefaultCurrency();
12 | const currencies = useCurrencyMap();
13 | return useCallback(
14 | (value: number, currency: ID, date: SDate) =>
15 | changeCurrencyValue(userDefaultCurrency, currencies[currency]!, value, date),
16 | [userDefaultCurrency, currencies]
17 | );
18 | };
19 |
--------------------------------------------------------------------------------
/src/state/shared/values.test.ts:
--------------------------------------------------------------------------------
1 | import { DateTime } from "luxon";
2 | import { expect, test } from "vitest";
3 | import { formatDate, parseDate } from "./values";
4 |
5 | test("Date formatters are reversible", () => {
6 | const date = DateTime.now().startOf("day");
7 | expect(parseDate(formatDate(date)).toISO()).toBe(date.toISO());
8 | });
9 |
--------------------------------------------------------------------------------
/src/state/shared/values.ts:
--------------------------------------------------------------------------------
1 | import chroma from "chroma-js";
2 | import { DateTime } from "luxon";
3 |
4 | /**
5 | * Colour Values
6 | */
7 | export const ColourScale = chroma.scale("set1");
8 | ColourScale.cache(false);
9 | export const getRandomColour = () => ColourScale(Math.random()).hex();
10 |
11 | /**
12 | * Value histories, monthly and in reverse order from current date
13 | */
14 | export interface BalanceHistory {
15 | start: SDate;
16 | original: number[]; // Value in transaction currency
17 | localised: number[]; // Value in user's base currency
18 | }
19 | export const BaseBalanceValues = (): BalanceHistory => ({
20 | start: getCurrentMonthString(),
21 | original: [],
22 | localised: [],
23 | });
24 |
25 | export interface TransactionHistory {
26 | start: SDate;
27 | credits: number[];
28 | debits: number[];
29 | count: number;
30 | }
31 | export const BaseTransactionHistory = (): TransactionHistory => ({
32 | start: getCurrentMonthString(),
33 | credits: [],
34 | debits: [],
35 | count: 0,
36 | });
37 |
38 | export interface TransactionHistoryWithLocalisation extends TransactionHistory {
39 | localCredits: number[];
40 | localDebits: number[];
41 | }
42 | export const BaseTransactionHistoryWithLocalisation = (): TransactionHistoryWithLocalisation => ({
43 | ...BaseTransactionHistory(),
44 | localCredits: [],
45 | localDebits: [],
46 | });
47 |
48 | /**
49 | * Dates
50 | */
51 | /* eslint-disable no-redeclare */
52 | export type ID = number;
53 | export type SDate = string & { __tag: "SDate" }; // YYYY-MM-DD
54 | export type STime = string & { __tag: "STime" }; // ISO Timestamp
55 |
56 | export const getNow = (): DateTime => DateTime.local();
57 | export const getTodayString = (): SDate => formatDate(getNow());
58 | export const getNowString = (): STime => formatDateTime(getNow());
59 | export const getCurrentMonth = (): DateTime => getNow().startOf("month");
60 | export const getCurrentMonthString = (): SDate => formatDate(getCurrentMonth());
61 |
62 | export function formatJSDate(date: Date): SDate;
63 | export function formatJSDate(date: Date | null): SDate | null;
64 | export function formatJSDate(date: Date | undefined): SDate | undefined;
65 | export function formatJSDate(date: Date | null | undefined): SDate | null | undefined;
66 | export function formatJSDate(date: Date | null | undefined): SDate | null | undefined {
67 | return date && formatDate(DateTime.fromJSDate(date));
68 | }
69 |
70 | export function formatDate(date: DateTime): SDate;
71 | export function formatDate(date: DateTime | null): SDate | null;
72 | export function formatDate(date: DateTime | undefined): SDate | undefined;
73 | export function formatDate(date: DateTime | null | undefined): SDate | null | undefined;
74 | export function formatDate(date: DateTime | null | undefined): SDate | null | undefined {
75 | return date && (date.toISODate() as SDate);
76 | }
77 |
78 | export function formatDateTime(date: DateTime): STime;
79 | export function formatDateTime(date: DateTime | null): STime | null;
80 | export function formatDateTime(date: DateTime | undefined): STime | undefined;
81 | export function formatDateTime(date: DateTime | null | undefined): STime | null | undefined;
82 | export function formatDateTime(date: DateTime | null | undefined): STime | null | undefined {
83 | return date && (date.toISO() as STime);
84 | }
85 |
86 | export function parseDate(date: SDate | STime): DateTime;
87 | export function parseDate(date: SDate | STime | null): DateTime | null;
88 | export function parseDate(date: SDate | STime | undefined): DateTime | undefined;
89 | export function parseDate(date: SDate | STime | null | undefined): DateTime | null | undefined;
90 | export function parseDate(date: SDate | STime | null | undefined): DateTime | null | undefined {
91 | return date == null ? (date as null | undefined) : DateTime.fromISO(date);
92 | }
93 |
--------------------------------------------------------------------------------
/src/styles/colours.ts:
--------------------------------------------------------------------------------
1 | export const WHITE = "#FFFFFF";
2 | export const BLACK = "#10161A";
3 |
4 | export const Greys = {
5 | "100": "#F7FAFC",
6 | "200": "#EDF2F7",
7 | "300": "#E2E8F0",
8 | "400": "#CBD5E0",
9 | "500": "#A0AEC0",
10 | "600": "#718096",
11 | "700": "#4A5568",
12 | "800": "#2D3748",
13 | "900": "#1A202C",
14 | };
15 | export const MissingValueColour = Greys[600];
16 |
17 | export const Intents = {
18 | app: {
19 | light: "#9179F2",
20 | main: "#7157D9",
21 | dark: "#634DBF",
22 | },
23 | primary: {
24 | light: "#48AFF0",
25 | main: "#137CBD",
26 | dark: "#0E5A8A",
27 | },
28 | success: {
29 | light: "#3DCC91",
30 | main: "#0F9960",
31 | dark: "#0A6640",
32 | },
33 | warning: {
34 | light: "#FFB366",
35 | main: "#D9822B",
36 | dark: "#A66321",
37 | },
38 | danger: {
39 | light: "#FF7373",
40 | main: "#DB3737",
41 | dark: "#A82A2A",
42 | },
43 | default: {
44 | light: Greys[400],
45 | main: Greys[500],
46 | dark: Greys[700],
47 | },
48 | };
49 |
50 | export const AppColours = {
51 | summary: {
52 | light: "#9179F2",
53 | main: "#7157D9",
54 | dark: "#634DBF",
55 | },
56 | accounts: {
57 | light: "#4580E6",
58 | main: "#2965CC",
59 | dark: "#2458B3",
60 | },
61 | transactions: {
62 | light: "#43BF4D",
63 | main: "#29A634",
64 | dark: "#238C2C",
65 | },
66 | categories: {
67 | light: "#F2B824",
68 | main: "#D99E0B",
69 | dark: "#BF8C0A",
70 | },
71 | forecasts: {
72 | light: "#EB532D",
73 | main: "#D13913",
74 | dark: "#B83211",
75 | },
76 | };
77 |
--------------------------------------------------------------------------------
/src/styles/theme.ts:
--------------------------------------------------------------------------------
1 | import { unstable_createMuiStrictModeTheme as createMuiTheme } from "@mui/material";
2 | import { AppColours, Greys, Intents, WHITE } from "./colours";
3 |
4 | // declare module "@mui/material/styles" {
5 | // interface Palette {
6 | // neutral: Palette["primary"];
7 | // }
8 | // interface PaletteOptions {
9 | // neutral: PaletteOptions["primary"];
10 | // }
11 | // }
12 |
13 | // // This is necessary to ensure that the DefaultTheme used by typescript fully inherits everything from Theme
14 | // declare module "@mui/styles/defaultTheme" {
15 | // // eslint-disable-next-line @typescript-eslint/no-empty-interface
16 | // interface DefaultTheme extends Theme {}
17 | // }
18 |
19 | export const APP_BACKGROUND_COLOUR = Greys[100];
20 | export const TopHatTheme = createMuiTheme({
21 | components: {
22 | MuiButton: {
23 | variants: [
24 | {
25 | props: { variant: "outlined", color: "inherit" },
26 | style: {
27 | borderColor: `rgba(0, 0, 0, 0.23)`,
28 | },
29 | },
30 | ],
31 | },
32 | },
33 | palette: {
34 | app: {
35 | ...AppColours.summary,
36 | contrastText: "white",
37 | },
38 | white: {
39 | main: WHITE,
40 | },
41 | background: {
42 | default: APP_BACKGROUND_COLOUR,
43 | },
44 | primary: {
45 | main: Intents.primary.main,
46 | },
47 | secondary: {
48 | main: Intents.danger.main,
49 | },
50 | // neutral: {
51 | // main: Greys[700],
52 | // },
53 | success: {
54 | main: Intents.success.main,
55 | },
56 | warning: {
57 | main: Intents.warning.main,
58 | },
59 | error: {
60 | main: Intents.danger.main,
61 | },
62 | },
63 | spacing: 1,
64 | // This messes with the default MUI component styling - better to manage manually
65 | // shape: {
66 | // borderRadius: 1,
67 | // },
68 | });
69 |
70 | export const getThemeTransition = TopHatTheme.transitions.create;
71 |
72 | export const DEFAULT_BORDER_RADIUS = 4;
73 |
74 | declare module "@mui/material/styles" {
75 | interface Palette {
76 | app: Palette["primary"];
77 | white: Palette["primary"];
78 | }
79 | interface PaletteOptions {
80 | app: PaletteOptions["primary"];
81 | white: PaletteOptions["primary"];
82 | }
83 | }
84 |
85 | declare module "@mui/material/Button" {
86 | interface ButtonPropsColorOverrides {
87 | app: true;
88 | white: true;
89 | }
90 | }
91 | declare module "@mui/material/IconButton" {
92 | interface IconButtonPropsColorOverrides {
93 | app: true;
94 | white: true;
95 | }
96 | }
97 |
--------------------------------------------------------------------------------
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "useDefineForClassFields": true,
5 | "lib": ["DOM", "DOM.Iterable", "ESNext"],
6 | "allowJs": false,
7 | "skipLibCheck": true,
8 | "esModuleInterop": false,
9 | "allowSyntheticDefaultImports": true,
10 | "strict": true,
11 | "forceConsistentCasingInFileNames": true,
12 | "noFallthroughCasesInSwitch": true,
13 | "module": "ESNext",
14 | "moduleResolution": "Node",
15 | "resolveJsonModule": true,
16 | "isolatedModules": true,
17 | "noEmit": true,
18 | "jsx": "react-jsx",
19 | "types": ["vite-plugin-pwa/client", "vite-plugin-pwa/vanillajs"]
20 | },
21 | "include": ["src"],
22 | "references": [{ "path": "./tsconfig.node.json" }]
23 | }
24 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "module": "ESNext",
5 | "moduleResolution": "Node",
6 | "allowSyntheticDefaultImports": true
7 | },
8 | "include": ["vite.config.ts"]
9 | }
10 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import react from "@vitejs/plugin-react-swc";
2 | import { defineConfig } from "vite";
3 | import { VitePWA } from "vite-plugin-pwa";
4 |
5 | // https://vitejs.dev/config/
6 | export default defineConfig({
7 | plugins: [
8 | react(),
9 | VitePWA({
10 | workbox: {
11 | globPatterns: ["**/*.{js,css,html,png,woff2,svg}"],
12 | },
13 | }),
14 | ],
15 | base: "/TopHat",
16 | });
17 |
--------------------------------------------------------------------------------