>;
5 | export type FCWithChildren = React.FC>;
6 |
--------------------------------------------------------------------------------
/src/components/table/containers/TableContainer.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Section, SectionProps } from "../../layout";
3 |
4 | type TableContainerProps = Pick;
5 | export const TableContainer: React.FC = (props) => ;
6 |
--------------------------------------------------------------------------------
/src/dialog/objects/shared/index.tsx:
--------------------------------------------------------------------------------
1 | export { DraggableDialogObjectSelector } from "./draggable";
2 | export { ObjectEditContainer } from "./edit";
3 | export { BasicDialogObjectSelector } from "./selector";
4 | export { DialogObjectOptionsBox, DialogSelectorAddNewButton } from "./shared";
5 | export { getUpdateFunctions } from "./update";
6 |
--------------------------------------------------------------------------------
/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/pages/accounts/index.tsx:
--------------------------------------------------------------------------------
1 | import { Page } from "../../components/layout";
2 | import { AccountsPageSummary } from "./summary";
3 | import { AccountsTable } from "./table";
4 |
5 | export const AccountsPage: React.FC = () => (
6 |
7 |
8 |
9 |
10 | );
11 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/src/dialog/import/index.tsx:
--------------------------------------------------------------------------------
1 | import { useDialogState } from "../../state/app/hooks";
2 | import { DialogImportScreen } from "./import";
3 | import { DialogImportFileScreen } from "./upload";
4 |
5 | export const DialogImportView: React.FC = () => {
6 | const page = useDialogState("import", (state) => state.page);
7 | return page === "file" ? : ;
8 | };
9 |
--------------------------------------------------------------------------------
/src/pages/categories/index.tsx:
--------------------------------------------------------------------------------
1 | import { Page } from "../../components/layout";
2 | import { CategoriesPageSummary } from "./summary";
3 | import { CategoryTable } from "./table";
4 |
5 | export const CategoriesPage: React.FC = () => {
6 | return (
7 |
8 |
9 |
10 |
11 | );
12 | };
13 |
--------------------------------------------------------------------------------
/src/components/table/index.tsx:
--------------------------------------------------------------------------------
1 | export { TableHeaderContainer } from "./containers/TableHeaderContainer";
2 | export { FilterIcon } from "./filters/FilterIcon";
3 | export { FilterMenuNestedOption } from "./filters/FilterMenuNestedOption";
4 | export { FilterMenuOption } from "./filters/FilterMenuOption";
5 | export { filterListByID, filterListByIDs } from "./filters/shared";
6 | export { TransactionsTable } from "./table";
7 |
--------------------------------------------------------------------------------
/src/dialog/import/contents/shared.tsx:
--------------------------------------------------------------------------------
1 | import { useDialogState } from "../../../state/app/hooks";
2 | import {
3 | DialogStatementImportState,
4 | DialogStatementMappingState,
5 | DialogStatementParseState,
6 | } from "../../../state/app/statementTypes";
7 |
8 | export const useNonFileDialogStatementState = () =>
9 | useDialogState("import") as DialogStatementParseState | DialogStatementMappingState | DialogStatementImportState;
10 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/src/components/summary/index.tsx:
--------------------------------------------------------------------------------
1 | import styled from "@emotion/styled";
2 | import { SECTION_MARGIN } from "../layout";
3 |
4 | export * from "./bar";
5 | export * from "./breakdown";
6 | export * from "./data";
7 |
8 | export const SummarySection = styled("div")({
9 | display: "flex",
10 |
11 | "& > div:first-of-type": {
12 | flex: "300px 0 0",
13 | marginRight: SECTION_MARGIN,
14 | },
15 |
16 | "& > div:last-child": {
17 | flexGrow: 1,
18 | },
19 | });
20 |
--------------------------------------------------------------------------------
/src/dialog/import/contents/file.tsx:
--------------------------------------------------------------------------------
1 | import styled from "@emotion/styled";
2 | import { Card } from "@mui/material";
3 | import React from "react";
4 |
5 | export const ImportDialogFileDisplay: React.FC<{ contents: string }> = ({ contents }) => (
6 |
7 | {contents}
8 |
9 | );
10 |
11 | const ContainerCard = styled(Card)({
12 | margin: "20px 20px 0 20px",
13 | padding: "10px 15px",
14 | overflow: "auto",
15 |
16 | "& > pre": { margin: 0 },
17 | });
18 |
--------------------------------------------------------------------------------
/src/pages/forecasts/index.tsx:
--------------------------------------------------------------------------------
1 | import { Page } from "../../components/layout";
2 | import { ForecastPageDebtCalculator } from "./debt";
3 | import { ForecastPageNetWorthCalculator } from "./net";
4 | import { ForecastPagePensionCalculator } from "./pension";
5 | import { ForecastPageRetirementCalculator } from "./retirement";
6 |
7 | export const ForecastPage: React.FC = () => (
8 |
9 |
10 |
11 |
12 |
13 |
14 | );
15 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "TopHat Finance",
3 | "name": "TopHat Finance",
4 | "description": "TopHat is a Personal Finance application which runs in the browser",
5 | "icons": [
6 | {
7 | "src": "favicon.png",
8 | "sizes": "89x90",
9 | "type": "image/x-icon"
10 | },
11 | {
12 | "src": "logo_144x144.png",
13 | "type": "image/png",
14 | "sizes": "144x144"
15 | }
16 | ],
17 | "start_url": "/TopHat/summary",
18 | "display": "standalone",
19 | "theme_color": "#7157D9",
20 | "background_color": "#F7FAFC"
21 | }
22 |
--------------------------------------------------------------------------------
/src/dialog/import/contents/table/shared.tsx:
--------------------------------------------------------------------------------
1 | import { buttonClasses } from "@mui/material";
2 | import { Greys } from "../../../../styles/colours";
3 |
4 | export const DIALOG_IMPORT_TABLE_HEADER_STYLES = {
5 | background: Greys[200],
6 | borderBottom: "2px solid " + Greys[400],
7 |
8 | position: "sticky",
9 | top: 0,
10 | zIndex: 2,
11 | } as const;
12 |
13 | export const DIALOG_IMPORT_TABLE_ROW_STYLES = {
14 | borderTop: "1px solid " + Greys[300],
15 | } as const;
16 |
17 | export const DIALOG_IMPORT_TABLE_ICON_BUTTON_STYLES = {
18 | padding: 0,
19 |
20 | [`& .${buttonClasses.endIcon}`]: {
21 | marginLeft: "-1px !important",
22 | },
23 | } as const;
24 |
--------------------------------------------------------------------------------
/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/pages/accounts/table/styles.tsx:
--------------------------------------------------------------------------------
1 | import styled from "@emotion/styled";
2 |
3 | export const ACCOUNT_TABLE_LEFT_PADDING = 19;
4 |
5 | export const AccountsTableIconSx = {
6 | height: 40,
7 | flex: "0 0 40px",
8 | margin: "30px 17px 30px 27px",
9 | borderRadius: "5px",
10 | };
11 | export const AccountsTableInstitutionBox = styled("div")({
12 | flex: "3 0 100px",
13 | display: "flex",
14 | flexDirection: "column",
15 | height: 100,
16 | justifyContent: "center",
17 | alignItems: "flex-start",
18 | minWidth: 0,
19 | });
20 | export const AccountsTableAccountsBox = styled("div")({
21 | flex: "1 1 850px",
22 | display: "flex",
23 | flexDirection: "column",
24 | alignItems: "stretch",
25 | margin: 16,
26 | });
27 |
--------------------------------------------------------------------------------
/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/src/components/table/containers/TableHeaderContainer.tsx:
--------------------------------------------------------------------------------
1 | import styled from "@emotion/styled";
2 | import { Card, Theme } from "@mui/material";
3 | import { SxProps } from "@mui/system";
4 | import { FCWithChildren } from "../../../shared/types";
5 | import { APP_BACKGROUND_COLOUR } from "../../../styles/theme";
6 |
7 | export const TableHeaderContainer: FCWithChildren<{ sx?: SxProps }> = ({ children, sx }) => {
8 | return (
9 |
10 |
11 | {children}
12 |
13 |
14 | );
15 | };
16 |
17 | const ContainerBox = styled("div")({
18 | top: 0,
19 | position: "sticky",
20 | backgroundColor: APP_BACKGROUND_COLOUR,
21 | zIndex: 2,
22 | margin: "-20px -10px 5px -10px",
23 | padding: "20px 10px 0 10px",
24 | });
25 | const HeaderCard = styled(Card)({
26 | height: 50,
27 | display: "flex",
28 | alignItems: "center",
29 | });
30 |
--------------------------------------------------------------------------------
/public/404.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | TopHat Finance
6 |
26 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/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/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/dialog/shared/layout.tsx:
--------------------------------------------------------------------------------
1 | import styled from "@emotion/styled";
2 | import { Box, BoxProps } from "@mui/system";
3 | import React from "react";
4 | import { stopEventPropagation } from "../../shared/events";
5 | import { Greys } from "../../styles/colours";
6 |
7 | /**
8 | * Dialog Layout Components
9 | */
10 | export const DialogMain = styled("div")({
11 | display: "flex",
12 | backgroundColor: Greys[200],
13 | minHeight: 0,
14 | flexGrow: 1,
15 | });
16 |
17 | export const DIALOG_OPTIONS_WIDTH = 312;
18 | export const DialogOptions = styled("div")({
19 | display: "flex",
20 | flexDirection: "column",
21 | width: DIALOG_OPTIONS_WIDTH,
22 | flexShrink: 0,
23 | });
24 |
25 | const DialogContentsBox = styled(Box)({
26 | display: "flex",
27 | justifyContent: "stretch",
28 | flexDirection: "column",
29 | margin: "12px 12px 12px 0",
30 | backgroundColor: Greys[100],
31 | borderRadius: "5px",
32 | flexGrow: 1,
33 | overflow: "hidden",
34 | });
35 | export const DialogContents: React.FC = (props) => (
36 |
37 | );
38 |
--------------------------------------------------------------------------------
/src/components/display/FlexWidthChart.tsx:
--------------------------------------------------------------------------------
1 | import { Box, SxProps } from "@mui/system";
2 | import React, { useEffect, useState } from "react";
3 | import { VictoryChart } from "victory";
4 | import { useDivBoundingRect } from "../../shared/hooks";
5 |
6 | interface FlexWidthChartProps {
7 | getChart: (width: number) => React.ReactElement;
8 | style?: React.CSSProperties;
9 | sx?: SxProps;
10 | }
11 | export const FlexWidthChart: React.FC = ({ getChart, style = {}, sx }) => {
12 | const [{ width }, ref] = useDivBoundingRect();
13 |
14 | const [chart, setChart] = useState();
15 | useEffect(() => {
16 | // The chart is first rendered with a bounding box of 0 * 0. In that case, we return undefined
17 | if (!width) return;
18 |
19 | const chart = getChart(width);
20 | if (!React.isValidElement(chart)) return;
21 |
22 | setChart(React.cloneElement(chart, { width } as any));
23 | }, [width, getChart]);
24 |
25 | return (
26 |
27 | {chart}
28 |
29 | );
30 | };
31 |
--------------------------------------------------------------------------------
/src/pages/categories/table/styles.tsx:
--------------------------------------------------------------------------------
1 | import styled from "@emotion/styled";
2 |
3 | export const CategoriesTableFillbarSx = { flexGrow: 1, width: 200 };
4 | export const CategoriesTableTitleBox = styled("div")({ display: "flex", alignItems: "center", width: 200 });
5 | export const CategoriesTableMainSx = {
6 | flexGrow: 9,
7 | width: 650,
8 | display: "flex",
9 | alignItems: "center",
10 | paddingLeft: 15,
11 |
12 | "&:hover > div:last-of-type": {
13 | visibility: "visible",
14 | },
15 | } as const;
16 | export const CategoriesTableSubtitleSx = {
17 | flexGrow: 1,
18 | width: 200,
19 | alignItems: "center",
20 | textAlign: "left",
21 | } as const;
22 | export const CategoriesTableIconSx = {
23 | height: 20,
24 | width: 20,
25 | marginLeft: 30,
26 | marginRight: 20,
27 | };
28 | export const CategoriesTableTotalSx = {
29 | width: 250,
30 | flexGrow: 1,
31 | display: "flex",
32 | justifyContent: "flex-end",
33 | alignItems: "flex-end",
34 | marginRight: 20,
35 | };
36 | export const CategoriesTableActionBox = styled("div")({ marginLeft: 20, width: 40, visibility: "hidden" });
37 |
--------------------------------------------------------------------------------
/src/dialog/import/steps/shared.tsx:
--------------------------------------------------------------------------------
1 | import styled from "@emotion/styled";
2 | import { inputClasses, TextField } from "@mui/material";
3 | import { Greys } from "../../../styles/colours";
4 |
5 | export const DialogImportOptionsContainerBox = styled("div")({ maxHeight: 220, overflowY: "auto" });
6 |
7 | export const DialogImportOptionBox = styled("div")({
8 | height: 40,
9 | display: "flex",
10 | marginRight: 19,
11 | alignItems: "center",
12 |
13 | "& p": { color: Greys[900] },
14 | "& > p:first-of-type": { flexGrow: 1 },
15 | });
16 |
17 | export const DialogImportOptionTitleContainerBox = styled("div")({
18 | flexGrow: 1,
19 | display: "flex",
20 | alignItems: "center",
21 |
22 | "& p": { marginRight: 3 },
23 | });
24 |
25 | export const DialogImportActionsBox = styled("div")({
26 | display: "flex",
27 | float: "right",
28 | marginTop: 15,
29 | marginRight: 19,
30 |
31 | "& > *": { marginRight: "15px !important" },
32 | });
33 |
34 | export const DialogImportInputTextField = styled(TextField)({
35 | width: 120,
36 | marginTop: 4,
37 |
38 | [`& .${inputClasses.input}`]: { textAlign: "center" },
39 | });
40 |
--------------------------------------------------------------------------------
/src/pages/transactions/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Page } from "../../components/layout";
3 | import { TransactionsTable } from "../../components/table";
4 | import { TransactionsTableFilters, TransactionsTableState } from "../../components/table/table/types";
5 | import { TopHatDispatch } from "../../state";
6 | import { AppSlice } from "../../state/app";
7 | import { useTransactionsPageState } from "../../state/app/hooks";
8 | import { TransactionsPageSummary } from "./summary";
9 |
10 | export const TransactionsPage: React.FC = () => {
11 | const { filters, state } = useTransactionsPageState((state) => state.table);
12 |
13 | return (
14 |
15 |
16 |
17 |
18 | );
19 | };
20 |
21 | const setFilters = (filters: TransactionsTableFilters) =>
22 | TopHatDispatch(AppSlice.actions.setTransactionsTablePartial({ filters }));
23 |
24 | const setState = (state: TransactionsTableState) =>
25 | TopHatDispatch(AppSlice.actions.setTransactionsTablePartial({ state }));
26 |
--------------------------------------------------------------------------------
/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/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/dialog/settings/shared.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Typography } from "@mui/material";
2 | import { FCWithChildren } from "../../shared/types";
3 | import { Greys } from "../../styles/colours";
4 |
5 | export const SettingsDialogPage: FCWithChildren<{ title: string }> = ({ title, children }) => (
6 |
17 |
18 | {title}
19 |
20 | {children}
21 |
22 | );
23 |
24 | export const SettingsDialogDivider: React.FC = () => (
25 |
26 | );
27 |
28 | export const SettingsDialogContents: FCWithChildren = ({ children }) => (
29 |
39 | {children}
40 |
41 | );
42 |
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | import "@fontsource/roboto/300-italic.css";
2 | import "@fontsource/roboto/300.css";
3 | import "@fontsource/roboto/400-italic.css";
4 | import "@fontsource/roboto/400.css";
5 | import "@fontsource/roboto/500.css";
6 | import "@fontsource/roboto/700.css";
7 | import { createRoot } from "react-dom/client";
8 | import { registerSW } from "virtual:pwa-register";
9 | import { App } from "./app";
10 | import { setPopupAlert } from "./app/popups";
11 | import { initialiseAndGetDBConnection } from "./state/logic/startup";
12 |
13 | initialiseAndGetDBConnection().then(() => {
14 | const root = createRoot(document.getElementById("root")!);
15 | root.render();
16 | });
17 |
18 | if ("serviceWorker" in navigator) {
19 | // && !/localhost/.test(window.location)) {
20 | const updateSW = registerSW({
21 | onNeedRefresh: () =>
22 | setPopupAlert({
23 | message: "New version available!",
24 | severity: "info",
25 | duration: null,
26 | action: {
27 | name: "Refresh",
28 | callback: () => updateSW(),
29 | },
30 | }),
31 | onOfflineReady: () =>
32 | setPopupAlert({ message: "App ready for offline use!", severity: "info", duration: null }),
33 | });
34 | }
35 |
--------------------------------------------------------------------------------
/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/components/table/filters/FilterIcon.tsx:
--------------------------------------------------------------------------------
1 | import { FilterList } from "@mui/icons-material";
2 | import { IconButton, IconButtonProps } from "@mui/material";
3 | import { upperFirst } from "lodash";
4 | import React, { ReactNode } from "react";
5 | import { withSuppressEvent } from "../../../shared/events";
6 | import { IconType } from "../../../shared/types";
7 | import { Intents } from "../../../styles/colours";
8 | import { getThemeTransition } from "../../../styles/theme";
9 |
10 | export const FilterIcon: React.FC<{
11 | ButtonProps?: IconButtonProps;
12 | badgeContent: ReactNode;
13 | margin?: "left" | "right" | "none";
14 | Icon?: IconType;
15 | onRightClick?: () => void;
16 | }> = ({ ButtonProps = {}, badgeContent, margin = "left", Icon = FilterList, onRightClick }) => (
17 | (onRightClick)}
28 | >
29 |
30 |
31 | );
32 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 | TopHat Finance
12 |
13 |
14 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/src/pages/accounts/table/index.tsx:
--------------------------------------------------------------------------------
1 | import { FormControlLabel, Switch } from "@mui/material";
2 | import React from "react";
3 | import { Section } from "../../../components/layout";
4 | import { TopHatDispatch } from "../../../state";
5 | import { AppSlice } from "../../../state/app";
6 | import { useAccountsPageState } from "../../../state/app/hooks";
7 | import { useAccountsTableData } from "./data";
8 | import { AccountsTableHeader } from "./header";
9 | import { AccountsInstitutionDisplay } from "./institution";
10 |
11 | export const AccountsTable: React.FC = () => {
12 | const filterInactive = useAccountsPageState((state) => state.filterInactive);
13 | const institutions = useAccountsTableData();
14 |
15 | return (
16 | }
21 | label="Filter Inactive"
22 | />
23 | }
24 | emptyBody={true}
25 | >
26 |
27 | {institutions.map((institution) => (
28 |
29 | ))}
30 |
31 | );
32 | };
33 |
34 | const handleToggle = (event: React.ChangeEvent) =>
35 | TopHatDispatch(AppSlice.actions.setAccountsPagePartial({ filterInactive: event.target.checked }));
36 |
--------------------------------------------------------------------------------
/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/shared/events.ts:
--------------------------------------------------------------------------------
1 | import { CheckboxProps, SelectProps, TextFieldProps } from "@mui/material";
2 | import { ToggleButtonGroupProps } from "@mui/lab";
3 | import React from "react";
4 |
5 | export const stopEventPropagation = (event: React.MouseEvent | React.SyntheticEvent) => event.stopPropagation();
6 | export const suppressEvent = (event: React.MouseEvent | React.SyntheticEvent) => {
7 | event.stopPropagation();
8 | event.preventDefault();
9 | };
10 | export const withSuppressEvent =
11 | (callback: (event: React.MouseEvent) => void) =>
12 | (event: React.MouseEvent) => {
13 | suppressEvent(event);
14 | callback(event);
15 | };
16 |
17 | export const handleButtonGroupChange =
18 | (onChange: (t: T) => void): ToggleButtonGroupProps["onChange"] =>
19 | (event, value) =>
20 | onChange(value as T);
21 |
22 | export const handleSelectChange =
23 | (onChange: (t: T) => void): SelectProps["onChange"] =>
24 | (event) =>
25 | onChange(event.target.value as T);
26 |
27 | export const handleTextFieldChange =
28 | (onChange: (value: string) => void): TextFieldProps["onChange"] =>
29 | (event) =>
30 | onChange(event.target.value);
31 |
32 | export const handleCheckboxChange =
33 | (onChange: (value: boolean) => void): CheckboxProps["onChange"] =>
34 | (_, value) =>
35 | onChange(value);
36 |
37 | export const handleAutoCompleteChange =
38 | (onChange: (value: T[]) => void) =>
39 | (_: any, value: T[]) =>
40 | onChange(value);
41 |
--------------------------------------------------------------------------------
/src/components/display/NonIdealState.tsx:
--------------------------------------------------------------------------------
1 | import styled from "@emotion/styled";
2 | import { Typography } from "@mui/material";
3 | import chroma from "chroma-js";
4 | import React from "react";
5 | import { IconType } from "../../shared/types";
6 | import { Intents } from "../../styles/colours";
7 |
8 | interface NonIdealStateProps {
9 | icon: IconType;
10 | title: string;
11 | intent?: keyof typeof Intents;
12 | subtitle?: React.ReactNode;
13 | action?: React.ReactNode;
14 | }
15 | export const NonIdealState: React.FC = ({ icon: Icon, title, subtitle, intent, action }) => (
16 |
17 |
23 | {title}
24 | {subtitle ? (
25 | typeof subtitle === "string" ? (
26 | {subtitle}
27 | ) : (
28 | subtitle
29 | )
30 | ) : undefined}
31 | {action}
32 |
33 | );
34 |
35 | const ContainerBox = styled("div")({
36 | display: "flex",
37 | flexDirection: "column",
38 | alignItems: "center",
39 | textAlign: "center",
40 | margin: "auto",
41 | padding: 40,
42 | });
43 | const IconSx = { margin: 10, height: 50, width: 50 };
44 | const SubtitleTypography = styled(Typography)({
45 | opacity: 0.8,
46 | maxWidth: 300,
47 | textAlign: "center",
48 | margin: "5px 0 10px 0",
49 | });
50 |
--------------------------------------------------------------------------------
/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/dialog/objects/shared/shared.tsx:
--------------------------------------------------------------------------------
1 | import styled from "@emotion/styled";
2 | import { AddCircleOutline } from "@mui/icons-material";
3 | import { Button } from "@mui/material";
4 | import { upperFirst } from "lodash";
5 | import React from "react";
6 | import { withSuppressEvent } from "../../../shared/events";
7 | import { useAllObjects } from "../../../state/data/hooks";
8 | import { BasicObjectName, BasicObjectType } from "../../../state/data/types";
9 | import { ID } from "../../../state/shared/values";
10 | export { ObjectEditContainer } from "./edit";
11 | export { getUpdateFunctions } from "./update";
12 |
13 | export interface DialogObjectSelectorProps {
14 | type: Name;
15 | exclude?: ID[];
16 | createDefaultOption?: () => BasicObjectType[Name];
17 | onAddNew?: () => void;
18 | render: (option: BasicObjectType[Name]) => React.ReactNode;
19 | }
20 |
21 | export const useObjectsWithExclusionList = (type: Name, exclude?: ID[]) => {
22 | const options = useAllObjects(type);
23 | return exclude ? options.filter(({ id }) => !exclude.includes(id)) : options;
24 | };
25 |
26 | export const DialogObjectOptionsBox = styled("div")({
27 | overflowY: "auto",
28 | flexGrow: 1,
29 | marginTop: 5,
30 | });
31 |
32 | export const DialogSelectorAddNewButton: React.FC<{ onClick: () => void; type: string }> = ({ onClick, type }) => (
33 | }
36 | onClick={withSuppressEvent(onClick)}
37 | >
38 | New {upperFirst(type)}
39 |
40 | );
41 | const DialogSelectorBottomButton = styled(Button)({ margin: 20 });
42 |
--------------------------------------------------------------------------------
/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/dialog/objects/shared/update.ts:
--------------------------------------------------------------------------------
1 | import { cloneDeep } from "lodash";
2 | import { TopHatDispatch, TopHatStore } from "../../../state";
3 | import { AppSlice } from "../../../state/app";
4 | import { DataSlice } from "../../../state/data";
5 | import { BasicObjectName, BasicObjectType } from "../../../state/data/types";
6 | import { ID } from "../../../state/shared/values";
7 |
8 | export const getUpdateFunctions = (type: Type) => {
9 | type Option = BasicObjectType[Type];
10 |
11 | const get = (id: ID) => TopHatStore.getState().data[type].entities[Number(id)] as Option;
12 | const getWorkingCopy = () => cloneDeep(TopHatStore.getState().app.dialog[type] as Option);
13 | const set = (option?: Option) => TopHatDispatch(AppSlice.actions.setDialogPartial({ id: type, [type]: option }));
14 | const setPartial = (partial?: Partial