├── src ├── shared │ ├── constants.ts │ ├── types.ts │ └── events.ts ├── vite-env.d.ts ├── dialog │ ├── shared │ │ ├── index.tsx │ │ ├── layout.tsx │ │ └── edits.tsx │ ├── objects │ │ └── shared │ │ │ ├── index.tsx │ │ │ ├── shared.tsx │ │ │ ├── update.ts │ │ │ ├── selector.tsx │ │ │ └── draggable.tsx │ ├── import │ │ ├── index.tsx │ │ ├── contents │ │ │ ├── shared.tsx │ │ │ ├── file.tsx │ │ │ ├── table │ │ │ │ ├── shared.tsx │ │ │ │ └── transfer.tsx │ │ │ └── tabs.tsx │ │ ├── steps │ │ │ ├── shared.tsx │ │ │ ├── final.tsx │ │ │ └── parse.tsx │ │ └── account.tsx │ ├── settings │ │ ├── shared.tsx │ │ ├── debug.tsx │ │ ├── about.tsx │ │ ├── storage.tsx │ │ └── dropbox.svg │ ├── index.tsx │ └── header.tsx ├── components │ ├── inputs │ │ ├── index.tsx │ │ └── values.tsx │ ├── layout │ │ ├── index.tsx │ │ ├── page.tsx │ │ └── section.tsx │ ├── table │ │ ├── filters │ │ │ ├── shared.ts │ │ │ ├── FilterIcon.tsx │ │ │ ├── FilterMenuOption.tsx │ │ │ ├── FilterMenuNestedOption.tsx │ │ │ └── RangeFilters.tsx │ │ ├── containers │ │ │ ├── TableContainer.tsx │ │ │ └── TableHeaderContainer.tsx │ │ ├── index.tsx │ │ └── table │ │ │ └── types.tsx │ ├── summary │ │ ├── index.tsx │ │ └── shared.tsx │ ├── display │ │ ├── FlexWidthChart.tsx │ │ ├── NonIdealState.tsx │ │ ├── PerformantCharts.tsx │ │ ├── SummaryNumber.tsx │ │ └── BasicBarChart.tsx │ └── snapshot │ │ └── data.tsx ├── app │ ├── index.tsx │ ├── error.tsx │ ├── view.tsx │ ├── popups.tsx │ └── context.tsx ├── state │ ├── shared │ │ ├── values.test.ts │ │ ├── hooks.ts │ │ ├── dailycache.ts │ │ └── values.ts │ ├── index.ts │ ├── app │ │ ├── actions.ts │ │ ├── statementTypes.ts │ │ ├── hooks.ts │ │ ├── pageTypes.ts │ │ └── defaults.ts │ ├── logic │ │ ├── import.ts │ │ ├── notifications │ │ │ ├── shared.tsx │ │ │ ├── variants │ │ │ │ ├── dropbox.tsx │ │ │ │ ├── currency.tsx │ │ │ │ ├── idb.tsx │ │ │ │ ├── demo.tsx │ │ │ │ ├── uncategorised.tsx │ │ │ │ ├── milestone.tsx │ │ │ │ └── debt.tsx │ │ │ ├── types.ts │ │ │ └── index.tsx │ │ ├── statement │ │ │ ├── index.ts │ │ │ └── types.ts │ │ └── database.ts │ └── data │ │ ├── demo │ │ └── post.ts │ │ └── index.test.ts ├── pages │ ├── accounts │ │ ├── index.tsx │ │ └── table │ │ │ ├── styles.tsx │ │ │ ├── index.tsx │ │ │ ├── data.tsx │ │ │ └── institution.tsx │ ├── categories │ │ ├── index.tsx │ │ ├── table │ │ │ ├── styles.tsx │ │ │ ├── index.tsx │ │ │ └── data.tsx │ │ └── summary │ │ │ ├── placeholder.tsx │ │ │ ├── data.tsx │ │ │ └── index.tsx │ ├── forecasts │ │ ├── index.tsx │ │ └── data.tsx │ ├── transactions │ │ └── index.tsx │ ├── account │ │ ├── balances.tsx │ │ └── index.tsx │ ├── category │ │ ├── history.tsx │ │ └── index.tsx │ └── summary │ │ └── index.tsx ├── main.tsx └── styles │ ├── colours.ts │ └── theme.ts ├── .prettierrc.json ├── screenshot.png ├── public ├── favicon.png ├── robots.txt ├── logo_144x144.png ├── manifest.json └── 404.html ├── other └── HN Screenshot.png ├── tsconfig.node.json ├── .gitignore ├── vite.config.ts ├── tsconfig.json ├── index.html ├── .github └── workflows │ └── main.yml ├── package.json └── README.md /src/shared/constants.ts: -------------------------------------------------------------------------------- 1 | export const NBSP = "\u00A0"; 2 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "printWidth": 120 4 | } 5 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Athenodoros/TopHat/HEAD/screenshot.png -------------------------------------------------------------------------------- /src/dialog/shared/index.tsx: -------------------------------------------------------------------------------- 1 | export * from "./edits"; 2 | export * from "./layout"; 3 | -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Athenodoros/TopHat/HEAD/public/favicon.png -------------------------------------------------------------------------------- /src/components/inputs/index.tsx: -------------------------------------------------------------------------------- 1 | export * from "./objects"; 2 | export * from "./values"; 3 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/components/layout/index.tsx: -------------------------------------------------------------------------------- 1 | export { Page } from "./page"; 2 | export * from "./section"; 3 | -------------------------------------------------------------------------------- /other/HN Screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Athenodoros/TopHat/HEAD/other/HN Screenshot.png -------------------------------------------------------------------------------- /public/logo_144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Athenodoros/TopHat/HEAD/public/logo_144x144.png -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/app/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { TopHatContextProvider } from "./context"; 3 | import { View } from "./view"; 4 | 5 | export const App: React.FC = () => ( 6 | 7 | 8 | 9 | ); 10 | -------------------------------------------------------------------------------- /src/components/table/filters/shared.ts: -------------------------------------------------------------------------------- 1 | export const filterListByID = (list: number[], value: number | undefined) => filterListByIDs(list, [value as number]); 2 | export const filterListByIDs = (list: number[], values: number[]) => 3 | list.length === 0 || values.some((value) => list.includes(value)); 4 | -------------------------------------------------------------------------------- /src/shared/types.ts: -------------------------------------------------------------------------------- 1 | import { SvgIconTypeMap } from "@mui/material"; 2 | import { OverridableComponent } from "@mui/material/OverridableComponent"; 3 | 4 | export type IconType = OverridableComponent>; 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
43 | ); 44 | } 45 | 46 | return this.props.children; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tophat", 3 | "version": "0.1.0", 4 | "private": true, 5 | "homepage": "https://athenodoros.github.io/TopHat", 6 | "type": "module", 7 | "dependencies": { 8 | "@date-io/luxon": "^2.16.1", 9 | "@emotion/react": "^11.10.5", 10 | "@emotion/styled": "^11.10.5", 11 | "@fontsource/roboto": "^4.5.8", 12 | "@mui/icons-material": "^5.11.0", 13 | "@mui/lab": "^5.0.0-alpha.119", 14 | "@mui/material": "^5.11.8", 15 | "@mui/x-date-pickers": "^7.3.2", 16 | "@reduxjs/toolkit": "^1.9.2", 17 | "chroma-js": "^2.1.2", 18 | "dexie": "^3.2.3", 19 | "dexie-observable": "^4.0.1-beta.13", 20 | "jszip": "^3.10.1", 21 | "lodash-es": "^4.17.21", 22 | "luxon": "^3.2.1", 23 | "papaparse": "^5.3.1", 24 | "prettier": "^2.8.4", 25 | "react": "^18.2.0", 26 | "react-beautiful-dnd": "^13.1.1", 27 | "react-dom": "^18.2.0", 28 | "react-dropzone": "^14.2.3", 29 | "react-redux": "^8.0.5", 30 | "rfc6902": "^5.1.1", 31 | "victory": "^36.6.8", 32 | "vite-plugin-pwa": "^0.20.0" 33 | }, 34 | "scripts": { 35 | "dev": "vite --host 0.0.0.0", 36 | "build": "tsc && vite build --base=/TopHat/", 37 | "preview": "vite preview", 38 | "test": "vitest" 39 | }, 40 | "devDependencies": { 41 | "@types/chroma-js": "^2.1.5", 42 | "@types/jest": "^29.4.0", 43 | "@types/lodash-es": "^4.17.4", 44 | "@types/luxon": "^3.2.0", 45 | "@types/node": "^18.13.0", 46 | "@types/papaparse": "^5.3.7", 47 | "@types/react": "^18.0.27", 48 | "@types/react-beautiful-dnd": "^13.1.3", 49 | "@types/react-dom": "^18.0.10", 50 | "@types/react-redux": "^7.1.25", 51 | "@types/react-test-renderer": "^18.0.0", 52 | "@vitejs/plugin-react-swc": "^3.0.0", 53 | "jsdom": "^21.1.0", 54 | "typescript": "^4.9.5", 55 | "vite": "^4.1.0", 56 | "vitest": "^1.6.0" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/components/table/table/types.tsx: -------------------------------------------------------------------------------- 1 | import { Transaction } from "../../../state/data"; 2 | import { ID, SDate } from "../../../state/shared/values"; 3 | 4 | // Filters 5 | export interface TransactionsTableFilters { 6 | fromDate?: SDate; 7 | toDate?: SDate; 8 | valueFrom?: number; 9 | valueTo?: number; 10 | account: ID[]; 11 | category: ID[]; 12 | currency: ID[]; 13 | statement: ID[]; 14 | hideStubs: boolean; 15 | search: string; 16 | searchRegex: boolean; 17 | tableLimit: number; 18 | } 19 | export const DefaultTransactionsTableFilters: TransactionsTableFilters = { 20 | account: [], 21 | category: [], 22 | currency: [], 23 | statement: [], 24 | hideStubs: false, 25 | search: "", 26 | searchRegex: false, 27 | tableLimit: 50, 28 | }; 29 | 30 | // Internal State 31 | 32 | /** 33 | * This holds the edit state for the table, with three state possibilities: 34 | * A new transaction is being created: "edit" is a valid Transaction, but "edit.id" is not in the main store 35 | * One transaction is being edited: "edit" is a valid Transaction, and "edit.id" is in the main store 36 | * One or more transactions are being edited in the header: 37 | * - "edit" is a Partial, where undefined corresponds to mixed values in the transactions 38 | * - "edit.id" is undefined 39 | * - "selection" contains the list of IDs being edited 40 | */ 41 | export type EditTransactionState = { [K in keyof Transaction]?: Transaction[K] }; 42 | export interface TransactionsTableState { 43 | selection: ID[]; 44 | edit?: EditTransactionState; // if "id" is undefined, then it's the header 45 | } 46 | 47 | export const DefaultTransactionsTableState: TransactionsTableState = { 48 | selection: [], 49 | }; 50 | 51 | // Fixed state state 52 | export type TransactionsTableFixedDataState = 53 | | { 54 | type: "account"; 55 | account: ID; 56 | } 57 | | { 58 | type: "category"; 59 | category: ID; 60 | nested: boolean; 61 | }; 62 | -------------------------------------------------------------------------------- /src/pages/categories/summary/placeholder.tsx: -------------------------------------------------------------------------------- 1 | import { MenuItem, Select } from "@mui/material"; 2 | import { Section } from "../../../components/layout"; 3 | import { 4 | SummaryBarChart, 5 | SummaryBreakdown, 6 | SummarySection, 7 | useTransactionsSummaryData, 8 | } from "../../../components/summary"; 9 | import { handleSelectChange } from "../../../shared/events"; 10 | import { TopHatDispatch } from "../../../state"; 11 | import { AppSlice } from "../../../state/app"; 12 | import { useCategoriesPageState } from "../../../state/app/hooks"; 13 | import { CategoriesPageState } from "../../../state/app/pageTypes"; 14 | 15 | export const CategoriesPageNoBudgetSummary: React.FC = () => { 16 | const sign = useCategoriesPageState((state) => state.summarySign); 17 | const { data, length } = useTransactionsSummaryData("category"); 18 | 19 | return ( 20 | 21 |
22 | 29 |
30 |
34 | All Transactions 35 | Income 36 | Expenses 37 | 38 | } 39 | > 40 | 41 |
42 |
43 | ); 44 | }; 45 | 46 | const setChartSign = handleSelectChange((summarySign: CategoriesPageState["summarySign"]) => 47 | TopHatDispatch(AppSlice.actions.setCategoriesPagePartial({ summarySign })) 48 | ); 49 | -------------------------------------------------------------------------------- /src/pages/account/balances.tsx: -------------------------------------------------------------------------------- 1 | import { MenuItem, Select } from "@mui/material"; 2 | import { Box } from "@mui/system"; 3 | import { keys } from "lodash"; 4 | import { useState } from "react"; 5 | import { FlexWidthChart } from "../../components/display/FlexWidthChart"; 6 | import { Section } from "../../components/layout"; 7 | import { BalanceSnapshotSummaryNumbers, useAssetsSnapshot, useGetSummaryChart } from "../../components/snapshot"; 8 | import { handleSelectChange } from "../../shared/events"; 9 | import { useAccountPageAccount } from "../../state/app/hooks"; 10 | import { useCurrencyMap } from "../../state/data/hooks"; 11 | import { ID } from "../../state/shared/values"; 12 | 13 | export const AccountPageBalances: React.FC = () => { 14 | const account = useAccountPageAccount(); 15 | const currencies = useCurrencyMap(); 16 | 17 | const [currency, setCurrency] = useState("all"); 18 | const onChangeCurrency = handleSelectChange((value: ID | "all") => 19 | setCurrency(value === "all" ? "all" : Number(value)) 20 | ); 21 | 22 | const balanceData = useAssetsSnapshot(account.id, currency === "all" ? undefined : currency); 23 | const getChart = useGetSummaryChart(balanceData); 24 | 25 | return ( 26 |
30 | All Currencies 31 | {keys(account.balances).map((id) => ( 32 | 33 | ({currencies[id]!.ticker}) {currencies[id]!.name} 34 | 35 | ))} 36 | 37 | } 38 | > 39 | 40 |
41 | 42 |
43 | 44 |
45 |
46 | ); 47 | }; 48 | -------------------------------------------------------------------------------- /src/pages/categories/table/index.tsx: -------------------------------------------------------------------------------- 1 | import { MenuItem, Select } from "@mui/material"; 2 | import React from "react"; 3 | import { Section } from "../../../components/layout"; 4 | import { handleSelectChange } from "../../../shared/events"; 5 | import { TopHatDispatch } from "../../../state"; 6 | import { AppSlice } from "../../../state/app"; 7 | import { useCategoriesPageState } from "../../../state/app/hooks"; 8 | import { CategoriesPageState } from "../../../state/app/pageTypes"; 9 | import { useCategoriesTableData } from "./data"; 10 | import { CategoriesPageTableHeader } from "./header"; 11 | import { TopLevelCategoryTableView } from "./TopLevel"; 12 | 13 | export const CategoryTable: React.FC = () => { 14 | const { hideEmpty, tableMetric: metric, tableSign } = useCategoriesPageState(); 15 | const { options, graph, chartFunctions, getCategoryStatistics } = useCategoriesTableData( 16 | hideEmpty, 17 | metric, 18 | tableSign 19 | ); 20 | 21 | return ( 22 |
26 | Current Month 27 | Previous Month 28 | 12 Month Average 29 | 30 | } 31 | emptyBody={true} 32 | > 33 | 34 | {options.map((option) => ( 35 | 43 | ))} 44 |
45 | ); 46 | }; 47 | 48 | const setMetric = handleSelectChange((tableMetric: CategoriesPageState["tableMetric"]) => 49 | TopHatDispatch(AppSlice.actions.setCategoriesPagePartial({ tableMetric })) 50 | ); 51 | -------------------------------------------------------------------------------- /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/dialog/shared/edits.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import { Tooltip, Typography } from "@mui/material"; 3 | import { Box, SxProps } from "@mui/system"; 4 | import React from "react"; 5 | import { FCWithChildren } from "../../shared/types"; 6 | import { Greys } from "../../styles/colours"; 7 | 8 | export const EditValueContainer: FCWithChildren<{ label?: React.ReactNode; disabled?: string; sx?: SxProps }> = ({ 9 | label, 10 | children, 11 | disabled, 12 | sx, 13 | }) => { 14 | return ( 15 | 16 | 17 | 18 | 19 | {typeof label === "string" ? ( 20 | 21 | {label} 22 | 23 | ) : ( 24 | label 25 | )} 26 | 27 | {children} 28 | 29 | 30 | 31 | ); 32 | }; 33 | 34 | const ContainerBox = styled(Box)({ display: "flex", alignItems: "center" }); 35 | const DisabledContainerSx = { pointerEvents: "none", opacity: 0.3 } as const; 36 | const OuterBox = styled("div")({ 37 | margin: "15px 0", 38 | "&:first-of-type": { marginTop: 10 }, 39 | "&:last-child": { marginBottom: 10 }, 40 | }); 41 | const LabelContainerBox = styled("div")({ 42 | flex: "0 0 150px", 43 | display: "flex", 44 | alignItems: "center", 45 | justifyContent: "flex-end", 46 | paddingRight: "30px", 47 | }); 48 | const LabelTypography = styled(Typography)({ color: Greys[600], textTransform: "uppercase" }); 49 | 50 | export const EditTitleContainer: React.FC<{ title: string }> = ({ title }) => ( 51 | 52 | {title} 53 | 54 | ); 55 | 56 | const TitleSx = { 57 | color: Greys[600], 58 | textTransform: "uppercase", 59 | marginTop: 20, 60 | } as const; 61 | -------------------------------------------------------------------------------- /src/components/display/PerformantCharts.tsx: -------------------------------------------------------------------------------- 1 | import { DateTime } from "luxon"; 2 | import React from "react"; 3 | import { VictoryAxis, VictoryChartProps } from "victory"; 4 | import { DomainTuple } from "victory-core"; 5 | import { BLACK } from "../../styles/colours"; 6 | 7 | const DummyComponent: React.FC = () =>
; 8 | 9 | export const getHiddenTickZeroAxis = (stroke: string = BLACK) => ( 10 | } 13 | tickLabelComponent={} 14 | axisValue={0.001} // There seems to be a bad falsiness check here, thus 0.001 15 | /> 16 | ); 17 | 18 | const formatDateValuesForAxis = (value: Date) => DateTime.fromJSDate(value).toFormat("LLL yyyy"); 19 | export const getBottomAlignedDateAxisFromDomain = (yDomain: [number, number], flip?: boolean) => 20 | getBottomAlignedDateAxis(yDomain[flip ? 1 : 0]); 21 | export const getBottomAlignedDateAxis = (value: number = 0) => ( 22 | 32 | ); 33 | 34 | // Victory renders charts using an incredibly slow recursive method for many props. 35 | // This fills in some of the major ones manually. 36 | export const getChartPerformanceProps = ( 37 | domain: { x: DomainTuple; y: DomainTuple }, 38 | scale: VictoryChartProps["scale"] = "linear", 39 | categories: VictoryChartProps["categories"] = [] 40 | ) => { 41 | if (!domain) return { domain, scale, categories }; 42 | if (domain.constructor === Array) return { domain: fixEmptyRange(domain), scale, categories }; 43 | 44 | return { 45 | domain: { 46 | x: fixEmptyRange(domain.x), 47 | y: fixEmptyRange(domain.y), 48 | }, 49 | scale, 50 | categories, 51 | }; 52 | }; 53 | const fixEmptyRange = (tuple: DomainTuple | undefined): DomainTuple | undefined => 54 | tuple && typeof tuple[0] === "number" ? (!tuple[0] && !tuple[1] ? [0, 0.1] : tuple) : tuple; 55 | -------------------------------------------------------------------------------- /src/app/view.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import React, { useEffect } from "react"; 3 | import { AccountPage } from "../pages/account"; 4 | import { AccountsPage } from "../pages/accounts"; 5 | import { CategoriesPage } from "../pages/categories"; 6 | import { CategoryPage } from "../pages/category"; 7 | import { ForecastPage } from "../pages/forecasts"; 8 | import { SummaryPage } from "../pages/summary"; 9 | import { TransactionsPage } from "../pages/transactions"; 10 | import { TopHatDispatch } from "../state"; 11 | import { PageStateType } from "../state/app/pageTypes"; 12 | import { DataSlice, setSubmitNotification } from "../state/data"; 13 | import { useSelector } from "../state/shared/hooks"; 14 | import { APP_BACKGROUND_COLOUR } from "../styles/theme"; 15 | import { NavBar } from "./navbar"; 16 | import { useSetAlert } from "./popups"; 17 | import { MIN_WIDTH_FOR_APPLICATION } from "./tutorial"; 18 | 19 | export const View: React.FC = () => { 20 | const page = useSelector((state) => state.app.page.id); 21 | const setAlert = useSetAlert(); 22 | useEffect( 23 | () => 24 | setSubmitNotification((id, message, intent) => 25 | setAlert({ 26 | message, 27 | severity: intent || "success", 28 | action: { 29 | name: "UNDO", 30 | callback: () => TopHatDispatch(DataSlice.actions.rewindToPatch(id)), 31 | }, 32 | }) 33 | ), 34 | [setAlert] 35 | ); 36 | 37 | return ( 38 | 39 | 40 | {Pages[page]} 41 | 42 | ); 43 | }; 44 | 45 | const Pages: Record = { 46 | summary: , 47 | accounts: , 48 | account: , 49 | transactions: , 50 | categories: , 51 | category: , 52 | forecasts: , 53 | }; 54 | 55 | const AppContainerBox = styled("div")({ 56 | height: "100vh", 57 | width: "100vw", 58 | minWidth: MIN_WIDTH_FOR_APPLICATION, 59 | display: "flex", 60 | backgroundColor: APP_BACKGROUND_COLOUR, 61 | "& *:focus": { outline: "none" }, 62 | }); 63 | -------------------------------------------------------------------------------- /src/components/layout/page.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import { Notifications as NotificationsIcon } from "@mui/icons-material"; 3 | import { Badge, IconButton, Popover, Typography } from "@mui/material"; 4 | import { NAVBAR_LOGO_HEIGHT } from "../../app/navbar"; 5 | import { Notifications } from "../../app/notifications"; 6 | import { usePopoverProps } from "../../shared/hooks"; 7 | import { FCWithChildren } from "../../shared/types"; 8 | import { useNotificationCount } from "../../state/data/hooks"; 9 | 10 | export const Page: FCWithChildren<{ title: string }> = ({ children, title }) => { 11 | const notifications = useNotificationCount(); 12 | const { buttonProps, popoverProps } = usePopoverProps(); 13 | 14 | return ( 15 | 16 | 17 | {title} 18 | 19 | 20 | 21 | 22 | 23 | 24 | 29 | 30 | 31 | 32 | 33 | {children} 34 | 35 | ); 36 | }; 37 | 38 | const PageContainerBox = styled("div")({ 39 | display: "flex", 40 | flexDirection: "column", 41 | alignItems: "stretch", 42 | flexGrow: 1, 43 | overflowY: "auto", 44 | padding: "0 60px 200px 60px", 45 | }); 46 | const TitleBox = styled("div")({ 47 | height: NAVBAR_LOGO_HEIGHT, 48 | flexShrink: 0, 49 | paddingTop: 4, 50 | 51 | display: "flex", 52 | alignItems: "center", 53 | justifyContent: "space-between", 54 | }); 55 | const TitleButtonsBox = styled("div")({ 56 | "& > button": { 57 | marginLeft: 15, 58 | borderRadius: "50%", 59 | }, 60 | }); 61 | -------------------------------------------------------------------------------- /src/components/layout/section.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import { Paper, Typography } from "@mui/material"; 3 | import { Box, SxProps } from "@mui/system"; 4 | import React from "react"; 5 | import { FCWithChildren } from "../../shared/types"; 6 | import { Greys } from "../../styles/colours"; 7 | import { getThemeTransition } from "../../styles/theme"; 8 | 9 | export const SECTION_MARGIN = 40; 10 | 11 | export interface SectionProps { 12 | title?: string; 13 | headers?: React.ReactNode | React.ReactNode[]; 14 | emptyBody?: boolean; 15 | onClick?: () => void; 16 | sx?: SxProps; 17 | PaperSx?: SxProps; 18 | } 19 | export const Section: FCWithChildren = ({ 20 | title, 21 | headers, 22 | children, 23 | emptyBody, 24 | onClick, 25 | sx, 26 | PaperSx, 27 | }) => { 28 | return ( 29 | 30 | {title || headers ? ( 31 | 32 | {title} 33 | {headers} 34 | 35 | ) : undefined} 36 | {emptyBody ? ( 37 | children 38 | ) : ( 39 | 40 | {children} 41 | 42 | )} 43 | 44 | ); 45 | }; 46 | 47 | const SectionBox = styled(Box)({ 48 | display: "flex", 49 | flexDirection: "column", 50 | justifyContent: "stretch", 51 | }); 52 | const SectionHeaderBox = styled("div")({ 53 | display: "flex", 54 | justifyContent: "space-between", 55 | alignItems: "center", 56 | marginBottom: 12, 57 | height: 32, 58 | flexShrink: 0, 59 | zIndex: 3, // For tables, so that the title is visible over the raised header 60 | 61 | "& > h6": { 62 | color: Greys[600], 63 | }, 64 | 65 | "& button": { 66 | color: Greys[600] + " !important", 67 | transition: getThemeTransition("color"), 68 | }, 69 | 70 | "& > div:last-child > *": { 71 | marginLeft: 20, 72 | }, 73 | }); 74 | const SectionBodyPaper = styled(Paper)({ 75 | marginBottom: 50, 76 | flexGrow: 1, 77 | padding: 20, 78 | }); 79 | -------------------------------------------------------------------------------- /src/dialog/settings/debug.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Typography } from "@mui/material"; 2 | import { TopHatDispatch } from "../../state"; 3 | import { DataSlice } from "../../state/data"; 4 | import { useUserData } from "../../state/data/hooks"; 5 | import { Greys } from "../../styles/colours"; 6 | import { EditValueContainer } from "../shared"; 7 | import { SettingsDialogContents, SettingsDialogDivider, SettingsDialogPage } from "./shared"; 8 | 9 | const ActionSx = { textAlign: "center", width: 100, height: 61 } as const; 10 | const ItalicsSx = { fontStyle: "italic", color: Greys[700] } as const; 11 | 12 | export const DialogDebugContents: React.FC = () => { 13 | const generation = useUserData((user) => user.generation); 14 | 15 | return ( 16 | 17 | 18 | If all goes well, you shouldn't need anything on this page. In case the numbers anywhere look wrong 19 | though, refreshing the caches might help: nothing here is destructive, so it doesn't hurt to try. 20 | 21 | 22 | 23 | 26 | {generation} 27 | 28 | } 29 | > 30 | 31 | TopHat schema version 32 | 33 | 34 | 37 | Refresh Caches 38 | 39 | } 40 | > 41 | 42 | Refresh all summaries and balances from the raw transaction data. 43 | 44 | 45 | 46 | 47 | ); 48 | }; 49 | 50 | const refreshCaches = () => TopHatDispatch(DataSlice.actions.refreshCaches()); 51 | -------------------------------------------------------------------------------- /src/components/table/filters/FilterMenuOption.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import { CheckBox, CheckBoxOutlineBlank } from "@mui/icons-material"; 3 | import { Checkbox, ListItemText, MenuItem } from "@mui/material"; 4 | import { SxProps } from "@mui/system"; 5 | import React from "react"; 6 | import { updateListSelection } from "../../../shared/data"; 7 | import { ID } from "../../../state/shared/values"; 8 | 9 | const IconSx = { 10 | height: 20, 11 | width: 20, 12 | borderRadius: "4px", 13 | marginRight: 10, 14 | }; 15 | const OptionListItemText = styled(ListItemText)({ 16 | padding: "4px 0", 17 | 18 | "& span": { 19 | textOverflow: "ellipsis", 20 | whiteSpace: "nowrap", 21 | overflow: "hidden", 22 | }, 23 | }); 24 | 25 | interface FilterMenuOptionProps { 26 | option: T; 27 | select: (ids: ID[]) => void; 28 | selected: ID[]; 29 | getOptionIcon: (option: T, sx: SxProps) => React.ReactNode; 30 | getSecondary?: (option: T) => string; 31 | } 32 | const FilterMenuOptionFunction = ( 33 | { option, select, selected, getOptionIcon, getSecondary }: FilterMenuOptionProps, 34 | ref: React.ForwardedRef 35 | ) => { 36 | return ( 37 |
38 | select(updateListSelection(option.id, selected))} 41 | > 42 | {getOptionIcon(option, IconSx)} 43 | 44 | } 46 | checkedIcon={} 47 | style={{ marginRight: 8 }} 48 | checked={selected.includes(option.id)} 49 | color="primary" 50 | /> 51 | 52 |
53 | ); 54 | }; 55 | 56 | /** 57 | * The material-ui `Menu` component passes in refs to its children - this allows a function component to use the ref. 58 | */ 59 | export const FilterMenuOption = React.forwardRef(FilterMenuOptionFunction) as ( 60 | props: FilterMenuOptionProps 61 | ) => React.ReactElement; 62 | -------------------------------------------------------------------------------- /src/dialog/index.tsx: -------------------------------------------------------------------------------- 1 | import { Dialog } from "@mui/material"; 2 | import { get } from "lodash"; 3 | import { useCallback, useContext, useEffect, useState } from "react"; 4 | import { FileHandlerContext } from "../app/context"; 5 | import { useDialogPage } from "../state/app/hooks"; 6 | import { closeDialogBox, DialogHeader } from "./header"; 7 | import { DialogImportView } from "./import"; 8 | import { DialogAccountsView } from "./objects/accounts"; 9 | import { DialogCategoriesView } from "./objects/categories"; 10 | import { DialogCurrenciesView } from "./objects/currencies"; 11 | import { DialogInstitutionsView } from "./objects/institutions"; 12 | import { DialogRulesView } from "./objects/rules"; 13 | import { DialogStatementView } from "./objects/statements"; 14 | import { DialogSettingsView } from "./settings"; 15 | import { DialogMain } from "./shared"; 16 | 17 | export const TopHatDialog: React.FC = () => { 18 | const state = useDialogPage(); 19 | const { dropzoneRef, isDragActive } = useContext(FileHandlerContext); 20 | 21 | const onClose = useCallback(() => !isDragActive && closeDialogBox(), [isDragActive]); 22 | 23 | // This triggers a re-render after initial load, once the ref is populated 24 | const reRender = useState(false)[1]; 25 | useEffect(() => void setTimeout(() => reRender(true), 0.1), [reRender]); 26 | 27 | if (!dropzoneRef?.current) return null; 28 | 29 | return ( 30 | 36 | 37 | {isDragActive ? DialogPages.import : get(DialogPages, state, )} 38 | 39 | ); 40 | }; 41 | 42 | const DialogPages = { 43 | account: , 44 | institution: , 45 | category: , 46 | currency: , 47 | rule: , 48 | statement: , 49 | import: , 50 | settings: , 51 | } as const; 52 | 53 | const DialogPaperSxProps = { 54 | sx: { 55 | width: 900, 56 | maxWidth: "inherit", 57 | height: 600, 58 | overflow: "hidden", 59 | }, 60 | }; 61 | -------------------------------------------------------------------------------- /src/pages/category/history.tsx: -------------------------------------------------------------------------------- 1 | import { BarChart } from "@mui/icons-material"; 2 | import { Box } from "@mui/system"; 3 | import { FlexWidthChart } from "../../components/display/FlexWidthChart"; 4 | import { SummaryNumber } from "../../components/display/SummaryNumber"; 5 | import { Section } from "../../components/layout"; 6 | import { 7 | TransactionSnapshotSummaryNumbers, 8 | useGetSummaryChart, 9 | useTransactionsSnapshot, 10 | } from "../../components/snapshot"; 11 | import { formatNumber, takeWithDefault } from "../../shared/data"; 12 | import { useCategoryPageCategory } from "../../state/app/hooks"; 13 | import { useDefaultCurrency } from "../../state/data/hooks"; 14 | 15 | export const CategoryPageHistory: React.FC = () => { 16 | const currency = useDefaultCurrency().symbol; 17 | 18 | const category = useCategoryPageCategory(); 19 | const history = useTransactionsSnapshot(category.id); 20 | const getChart = useGetSummaryChart( 21 | { 22 | trends: history.trends, 23 | net: category.budgets ? takeWithDefault(category.budgets.values, history.net.length, 0) : history.net, 24 | }, 25 | 260 26 | ); 27 | 28 | return ( 29 |
30 | 31 | 32 | 33 | {category.budgets ? ( 34 | 48 | ) : undefined} 49 | 50 | 51 | 52 |
53 | ); 54 | }; 55 | -------------------------------------------------------------------------------- /src/dialog/settings/about.tsx: -------------------------------------------------------------------------------- 1 | import { Camera } from "@mui/icons-material"; 2 | import { Link, Typography } from "@mui/material"; 3 | import { Box } from "@mui/system"; 4 | import React from "react"; 5 | import { AppColours, WHITE } from "../../styles/colours"; 6 | import { SettingsDialogPage } from "./shared"; 7 | 8 | export const DialogAboutContents: React.FC = () => { 9 | return ( 10 | 11 | 18 | 31 | 32 | 33 | 34 | 35 | TopHat is a Personal Finance application which runs in the browser. 36 | 37 | 38 | It lets you track balances and expenses across multiple currencies, while preserving your 39 | privacy: your data is stored on your computer, and you manage any external connections. Learn 40 | more{" "} 41 | 46 | here 47 | 48 | . 49 | 50 | 51 | 52 | 53 | ); 54 | }; 55 | -------------------------------------------------------------------------------- /src/pages/account/index.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import { useMemo } from "react"; 3 | import { Page, SECTION_MARGIN } from "../../components/layout"; 4 | import { TransactionsTable } from "../../components/table"; 5 | import { TransactionsTableFilters, TransactionsTableState } from "../../components/table/table/types"; 6 | import { TopHatDispatch } from "../../state"; 7 | import { AppSlice } from "../../state/app"; 8 | import { useAccountPageAccount, useAccountPageState } from "../../state/app/hooks"; 9 | import { AccountPageBalances } from "./balances"; 10 | import { AccountPageHeader } from "./header"; 11 | import { AccountStatementTable } from "./statements"; 12 | 13 | const MiddleBox = styled("div")({ 14 | display: "flex", 15 | "& > div:first-of-type": { 16 | flex: "2 0 700px", 17 | marginRight: SECTION_MARGIN, 18 | }, 19 | "& > div:last-child": { 20 | flex: "1 1 300px", 21 | }, 22 | }); 23 | 24 | export const AccountPage: React.FC = () => { 25 | const account = useAccountPageAccount(); 26 | const table = useAccountPageState((state) => state.table); 27 | const id = account?.id ?? -1; // Continue hooks in case Account is deleted while on page 28 | const fixed = useMemo(() => ({ type: "account" as const, account: id }), [id]); 29 | 30 | // "table" is only undefined when redirecting to AccountsPage after deletion 31 | const filters = useMemo(() => ({ ...table?.filters, account: [id] }), [table?.filters, id]); 32 | 33 | if (!account) { 34 | TopHatDispatch(AppSlice.actions.setPage("accounts")); 35 | return ; 36 | } 37 | 38 | return ( 39 | 40 | 41 | 42 | 43 | 44 | 45 | 52 | 53 | ); 54 | }; 55 | 56 | const setFilters = (filters: TransactionsTableFilters) => 57 | TopHatDispatch(AppSlice.actions.setAccountTableStatePartial({ filters })); 58 | 59 | const setState = (state: TransactionsTableState) => 60 | TopHatDispatch(AppSlice.actions.setAccountTableStatePartial({ state })); 61 | -------------------------------------------------------------------------------- /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/components/summary/shared.tsx: -------------------------------------------------------------------------------- 1 | import chroma from "chroma-js"; 2 | import React from "react"; 3 | import { VictoryStyleInterface } from "victory-core"; 4 | import { suppressEvent } from "../../shared/events"; 5 | 6 | export type SummaryChartSign = "credits" | "debits"; 7 | export interface ChartPoint { 8 | id: number; 9 | colour: string; 10 | sign: SummaryChartSign; 11 | } 12 | export interface ChartPointEvent { 13 | style: React.CSSProperties; 14 | datum: ChartPoint; 15 | } 16 | export const getChartEvents = ( 17 | onClick: (t: T) => void, 18 | highlightSeries: boolean = false 19 | ) => { 20 | const mutation = (alpha?: number, transition?: string) => ({ 21 | eventKey: highlightSeries ? "all" : undefined, 22 | mutation: (event: T) => { 23 | if (!alpha || !event) return; 24 | 25 | /** 26 | * event.datum should never be undefined, but Victory seems to have a bug where it doesn't update the 27 | * data when new props are given (mostly around starting the tutorial on the Transactions page). 28 | */ 29 | if (!event.datum) return; 30 | 31 | return { 32 | style: Object.assign({}, event.style, { fill: fadeColour(event.datum.colour, alpha), transition }), 33 | }; 34 | }, 35 | }); 36 | 37 | return [ 38 | { 39 | childName: "all", 40 | target: "data" as const, 41 | eventHandlers: { 42 | onMouseEnter: () => mutation(0.75, "none"), 43 | onMouseOut: () => mutation(), 44 | // onMouseDown has two, so that the styling obeys highlightSeries but onClick only triggers once 45 | onMouseDown: () => [mutation(1, "none"), { mutation: onClick }], 46 | onMouseUp: () => mutation(0.75, "fill 500ms cubic-bezier(0.4, 0, 0.2, 1) 0ms"), 47 | onClick: (event: React.SyntheticEvent) => { 48 | suppressEvent(event); 49 | return []; 50 | }, 51 | }, 52 | }, 53 | ]; 54 | }; 55 | 56 | const fadeColour = (colour: string, value: number) => colour && chroma(colour).alpha(value).hex(); 57 | 58 | export const getChartSectionStyles = (interactive: boolean): VictoryStyleInterface => ({ 59 | data: { 60 | cursor: interactive ? "pointer" : undefined, 61 | // Sometimes datum.colour is stripped for zero-height sections 62 | fill: ({ datum }) => fadeColour(datum.colour, 0.5)!, 63 | stroke: ({ datum }) => datum.colour, 64 | strokeWidth: 1, 65 | transition: "fill 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms", 66 | }, 67 | }); 68 | -------------------------------------------------------------------------------- /src/dialog/import/account.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import { KeyboardArrowDown } from "@mui/icons-material"; 3 | import { Button, Typography } from "@mui/material"; 4 | import React from "react"; 5 | import { useGetAccountIcon } from "../../components/display/ObjectDisplay"; 6 | import { ObjectSelector } from "../../components/inputs"; 7 | import { useDialogState } from "../../state/app/hooks"; 8 | import { useAllAccounts } from "../../state/data/hooks"; 9 | import { changeStatementDialogAccount } from "../../state/logic/statement"; 10 | import { Greys } from "../../styles/colours"; 11 | 12 | export const DialogImportAccountSelector: React.FC = () => { 13 | const account = useDialogState("import", (state) => state.account); 14 | const accounts = useAllAccounts(); 15 | const getAccountIcon = useGetAccountIcon(); 16 | 17 | return ( 18 | getAccountIcon(account, IconSx)} 21 | selected={account?.id} 22 | setSelected={changeStatementDialogAccount} 23 | placeholder={ 24 | <> 25 | {getAccountIcon(undefined, IconSx)} 26 | 27 | Enter Account 28 | 29 | 30 | } 31 | > 32 | 33 | 34 | {getAccountIcon(account, IconSx)} 35 | 40 | {account?.name || "Enter Account"} 41 | 42 | 43 | 44 | 45 | 46 | ); 47 | }; 48 | 49 | const AccountContainerBox = styled("div")({ margin: "12px 15px" }); 50 | const IconSx = { 51 | height: 20, 52 | width: 20, 53 | borderRadius: "4px", 54 | marginRight: 15, 55 | }; 56 | const AccountButton = styled(Button)({ 57 | height: 40, 58 | width: "100%", 59 | textTransform: "inherit", 60 | color: "inherit", 61 | }); 62 | const AccountButtonTypography = styled(Typography)({ flexGrow: 1, textAlign: "left" }); 63 | const PlaceholderSx = { 64 | fontStyle: "italic", 65 | color: Greys[600], 66 | }; 67 | -------------------------------------------------------------------------------- /src/components/display/SummaryNumber.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import { Typography } from "@mui/material"; 3 | import { Box } from "@mui/system"; 4 | import { NBSP } from "../../shared/constants"; 5 | import { IconType } from "../../shared/types"; 6 | import { AppColours, Greys, Intents, WHITE } from "../../styles/colours"; 7 | 8 | interface SummaryNumberProps { 9 | icon: IconType; 10 | primary: { 11 | value: string; 12 | positive: boolean | null; 13 | }; 14 | secondary?: { 15 | value: string; 16 | positive: boolean | null; 17 | }; 18 | subtext: string; 19 | } 20 | export const SummaryNumber: React.FC = ({ icon: Icon, primary, secondary, subtext }) => ( 21 | 22 | 23 |
24 | 34 | {primary.value} 35 | 36 | 37 | {secondary ? ( 38 | 48 | {secondary.value + NBSP} 49 | 50 | ) : undefined} 51 | 52 | {subtext} 53 | 54 | 55 |
56 |
57 | ); 58 | 59 | const SummaryNumberContainerBox = styled("div")({ 60 | display: "flex", 61 | width: 220, 62 | 63 | padding: "10px 0 20px 0", 64 | "&:last-child": { 65 | paddingBottom: 10, 66 | }, 67 | }); 68 | const IconSx = { 69 | backgroundColor: Greys[600], 70 | width: 38, 71 | height: 38, 72 | padding: 9, 73 | display: "flex", 74 | justifyContent: "center", 75 | alignItems: "center", 76 | color: WHITE, 77 | borderRadius: "50%", 78 | marginRight: 12, 79 | }; 80 | -------------------------------------------------------------------------------- /src/dialog/import/contents/tabs.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import { Clear } from "@mui/icons-material"; 3 | import { alpha, IconButton, Tab, tabClasses, Tabs } from "@mui/material"; 4 | import React from "react"; 5 | import { withSuppressEvent } from "../../../shared/events"; 6 | import { changeFileSelection, removeStatementFileFromDialog } from "../../../state/logic/statement"; 7 | import { Greys, Intents, WHITE } from "../../../styles/colours"; 8 | import { useNonFileDialogStatementState } from "./shared"; 9 | 10 | export const ImportDialogFileTabs: React.FC = () => { 11 | const state = useNonFileDialogStatementState(); 12 | const currentFileParsed = state.columns.all[state.file].matches; 13 | 14 | return ( 15 | 22 | {state.files.map((file) => ( 23 | {file.name}} 26 | value={file.id} 27 | sx={state.columns.all[file.id].matches ? undefined : { color: Intents.danger.main }} 28 | icon={ 29 | (() => removeStatementFileFromDialog(file.id))} 32 | sx={ 33 | state.columns.all[file.id].matches 34 | ? undefined 35 | : { color: alpha(Intents.danger.main, 0.6) } 36 | } 37 | > 38 | 39 | 40 | } 41 | component="div" 42 | wrapped={true} 43 | /> 44 | ))} 45 | 46 | ); 47 | }; 48 | 49 | const onFileChange = (_: React.SyntheticEvent, id: string) => changeFileSelection(id); 50 | 51 | const ContainerTabs = styled(Tabs)({ 52 | background: WHITE, 53 | borderBottom: "1px solid " + Greys[400], 54 | flexShrink: 0, 55 | 56 | [`& .${tabClasses.root}`]: { 57 | minHeight: 48, 58 | padding: "0 18px", 59 | flexDirection: "row-reverse", 60 | }, 61 | }); 62 | const TabIconButton = styled(IconButton)({ margin: "0 -5px 0 5px !important" }); 63 | const TabLabelWrapperSpan = styled("span")({ overflow: "hidden", textOverflow: "ellipsis", maxWidth: 150 }); 64 | -------------------------------------------------------------------------------- /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/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/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/pages/categories/summary/data.tsx: -------------------------------------------------------------------------------- 1 | import { mean } from "lodash"; 2 | import { useMemo } from "react"; 3 | import { SummaryBreakdownDatum } from "../../../components/summary"; 4 | import { formatNumber } from "../../../shared/data"; 5 | import { CategoriesPageState } from "../../../state/app/pageTypes"; 6 | import { useAllCategories } from "../../../state/data/hooks"; 7 | import { TRANSFER_CATEGORY_ID } from "../../../state/data/shared"; 8 | import { CategoriesMetricLookbackPeriods } from "../table/data"; 9 | import { CategoriesBarSummaryPoint } from "./budget"; 10 | 11 | export const useCategoryBudgetSummaryData = ( 12 | metric: CategoriesPageState["tableMetric"] 13 | ): (SummaryBreakdownDatum & CategoriesBarSummaryPoint)[] => { 14 | const categories = useAllCategories(); 15 | const lookback = CategoriesMetricLookbackPeriods[metric]; 16 | 17 | return useMemo(() => { 18 | return categories 19 | .filter(({ id, hierarchy }) => hierarchy.length === 0 && id !== TRANSFER_CATEGORY_ID) 20 | .map((category) => { 21 | const value = mean( 22 | lookback.map( 23 | (i) => (category.transactions.credits[i] || 0) + (category.transactions.debits[i] || 0) 24 | ) 25 | ); 26 | const budget = mean(lookback.map((i) => category.budgets?.values[i] || 0)); 27 | 28 | const debit = budget !== 0 ? budget < 0 : value < 0; 29 | 30 | return { 31 | // Common data 32 | id: category.id, 33 | name: category.name, 34 | colour: category.colour, 35 | 36 | // SummaryBreakdownDatum 37 | value: { 38 | credit: 0, 39 | debit: 0, 40 | [debit ? "debit" : "credit"]: value - budget, 41 | }, 42 | subtitle: category.budgets 43 | ? { 44 | base: "Monthly Budget", 45 | rollover: "Rollover", 46 | copy: "Repeated Budget", 47 | }[category.budgets!.strategy] 48 | : "No Budget", 49 | subValue: { 50 | type: "string", 51 | credit: "", 52 | debit: "", 53 | [debit ? "debit" : "credit"]: `${formatNumber(value, { end: "k" })} / ${ 54 | category.budgets ? formatNumber(budget, { end: "k" }) : "---" 55 | }`, 56 | }, 57 | debit, 58 | 59 | // CategoriesBarSummaryPoint 60 | total: value, 61 | budget, 62 | }; 63 | }); 64 | }, [categories, lookback]); 65 | }; 66 | -------------------------------------------------------------------------------- /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/pages/categories/summary/index.tsx: -------------------------------------------------------------------------------- 1 | import { MenuItem, Select } from "@mui/material"; 2 | import { Box } from "@mui/system"; 3 | import { Section, SECTION_MARGIN } from "../../../components/layout"; 4 | import { SummaryBreakdown } from "../../../components/summary"; 5 | import { handleSelectChange } from "../../../shared/events"; 6 | import { TopHatDispatch } from "../../../state"; 7 | import { AppSlice } from "../../../state/app"; 8 | import { useCategoriesPageState } from "../../../state/app/hooks"; 9 | import { CategoriesPageState } from "../../../state/app/pageTypes"; 10 | import { CategoriesBarSummary } from "./budget"; 11 | import { CategoriesBarChart } from "./chart"; 12 | import { useCategoryBudgetSummaryData } from "./data"; 13 | import { CategoriesPageNoBudgetSummary } from "./placeholder"; 14 | 15 | const HelpText: Record = { 16 | current: "All transactions in current month", 17 | previous: "All transactions in previous month", 18 | average: "Monthly average over previous 12 months", 19 | }; 20 | 21 | export const CategoriesPageSummary: React.FC = () => { 22 | const { summaryMetric: metric } = useCategoriesPageState((state) => state); 23 | const data = useCategoryBudgetSummaryData(metric); 24 | 25 | if (!data.some((category) => category.budget)) return ; 26 | 27 | return ( 28 | div:first-of-type": { flex: "350px 0 0", marginRight: SECTION_MARGIN }, 32 | "& > div:last-child": { flexGrow: 1 }, 33 | }} 34 | > 35 |
39 | Current Month 40 | Previous Month 41 | 12 Month Average 42 | , 43 | ]} 44 | PaperSx={{ height: 410, display: "flex", flexDirection: "column" }} 45 | > 46 | 54 | 55 | 56 |
57 | 58 |
59 | ); 60 | }; 61 | 62 | const setMetric = handleSelectChange((summaryMetric: CategoriesPageState["summaryMetric"]) => 63 | TopHatDispatch(AppSlice.actions.setCategoriesPagePartial({ summaryMetric })) 64 | ); 65 | -------------------------------------------------------------------------------- /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/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/components/table/filters/FilterMenuNestedOption.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import { ChevronRight } from "@mui/icons-material"; 3 | import { Badge, ListItemIcon, ListItemText, Menu, MenuItem, Popover } from "@mui/material"; 4 | import React from "react"; 5 | import { NBSP } from "../../../shared/constants"; 6 | import { suppressEvent } from "../../../shared/events"; 7 | import { usePopoverProps } from "../../../shared/hooks"; 8 | import { FCWithChildren, IconType } from "../../../shared/types"; 9 | 10 | const PaddedListItemText = styled(ListItemText)({ 11 | paddingTop: "4px", 12 | paddingBottom: "4px", 13 | }); 14 | 15 | interface FilterMenuNestedOptionProps { 16 | icon: IconType; 17 | name: string; 18 | count: number | boolean | undefined; 19 | PopoverComponent?: typeof Menu | typeof Popover; 20 | maxHeight?: number; 21 | } 22 | export const FilterMenuNestedOptionFunction = ( 23 | { 24 | icon: Icon, 25 | name, 26 | PopoverComponent = Menu, 27 | children, 28 | count = 0, 29 | maxHeight, 30 | }: React.PropsWithChildren, 31 | ref: React.ForwardedRef 32 | ) => { 33 | const { buttonProps, popoverProps, setIsOpen } = usePopoverProps(); 34 | 35 | return ( 36 |
37 | setIsOpen(true)} 42 | onMouseLeave={() => setIsOpen(false)} 43 | > 44 | 45 | 50 | 51 | 52 | 53 | {name + NBSP + NBSP} 54 | 55 | 62 |
{children}
63 |
64 |
65 |
66 | ); 67 | }; 68 | 69 | /** 70 | * The material-ui `Menu` component passes in refs to its children - this allows a function component to use the ref. 71 | */ 72 | export const FilterMenuNestedOption: FCWithChildren = 73 | React.forwardRef(FilterMenuNestedOptionFunction); 74 | -------------------------------------------------------------------------------- /src/pages/categories/table/data.tsx: -------------------------------------------------------------------------------- 1 | import { mean, range, sum, zip } from "lodash"; 2 | import { useMemo } from "react"; 3 | import { useCategoryGraph } from "../../../components/display/CategoryMenu"; 4 | import { getChartDomainFunctions } from "../../../shared/data"; 5 | import { CategoriesPageState } from "../../../state/app/pageTypes"; 6 | import { Category } from "../../../state/data"; 7 | 8 | export const CategoriesMetricLookbackPeriods: Record = { 9 | current: [0], 10 | previous: [1], 11 | average: range(1, 13), 12 | }; 13 | export const useCategoriesTableData = ( 14 | hideEmpty: CategoriesPageState["hideEmpty"], 15 | metric: CategoriesPageState["tableMetric"], 16 | tableSign: CategoriesPageState["tableSign"] 17 | ) => { 18 | const { options: ids, graph, entities } = useCategoryGraph(); 19 | const lookback = CategoriesMetricLookbackPeriods[metric]; 20 | 21 | const { options, chartFunctions, getCategoryStatistics } = useMemo(() => { 22 | const getCategoryStatistics = (category: Category) => { 23 | const value = 24 | mean( 25 | lookback.map( 26 | (i) => (category.transactions.credits[i] || 0) + (category.transactions.debits[i] || 0) 27 | ) 28 | ) * (tableSign === "debits" ? -1 : 1); 29 | const budget = category.budgets 30 | ? mean(lookback.map((i) => category.budgets!.values[i] || 0)) * (tableSign === "debits" ? -1 : 1) 31 | : undefined; 32 | const success = budget !== undefined ? (tableSign !== "debits" ? value >= budget : value <= budget) : null; 33 | 34 | return { value, budget, success }; 35 | }; 36 | 37 | let options = ids.map((id) => { 38 | const category = entities[id]!; 39 | const { value, budget, success } = getCategoryStatistics(category); 40 | 41 | const isDebitCategory = 42 | mean(zip(category.transactions.credits, category.transactions.debits).map(sum)) <= 0; 43 | 44 | return { 45 | id, 46 | name: category.name, 47 | colour: category.colour, 48 | value, 49 | budget, 50 | success, 51 | isDebitCategory, 52 | }; 53 | }); 54 | if (tableSign !== "all") 55 | options = options.filter((option) => option.isDebitCategory === (tableSign === "debits")); 56 | if (hideEmpty === "all") options = options.filter((option) => option.value); 57 | 58 | const chartFunctions = getChartDomainFunctions( 59 | options.map(({ value, budget }) => (Math.abs(value) > Math.abs(budget || 0) ? value : budget || 0)), 60 | 0.1 61 | ); 62 | 63 | return { options, chartFunctions, getCategoryStatistics }; 64 | }, [ids, entities, lookback, tableSign, hideEmpty]); 65 | 66 | return { options, graph, chartFunctions, getCategoryStatistics }; 67 | }; 68 | -------------------------------------------------------------------------------- /src/dialog/import/contents/table/transfer.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import { ImportExport } from "@mui/icons-material"; 3 | import { Button, ButtonBase, Collapse, svgIconClasses, Typography } from "@mui/material"; 4 | import React, { useMemo } from "react"; 5 | import { withSuppressEvent } from "../../../../shared/events"; 6 | import { Transaction } from "../../../../state/data"; 7 | import { toggleStatementRowTransfer } from "../../../../state/logic/statement"; 8 | import { DIALOG_IMPORT_TABLE_ICON_BUTTON_STYLES } from "./shared"; 9 | 10 | export const DialogImportTableTransferDisplay: React.FC<{ 11 | transfer: { 12 | transaction?: Transaction; 13 | excluded?: boolean; 14 | }; 15 | disabled: boolean; 16 | transfers?: boolean; 17 | file: string; 18 | row: number; 19 | }> = ({ disabled, transfer: { transaction, excluded }, transfers, file, row }) => { 20 | const onClick = useMemo(() => withSuppressEvent(() => toggleStatementRowTransfer(file, row)), [file, row]); 21 | if (!transaction) return null; 22 | 23 | return ( 24 | 25 | 35 | } 38 | color={excluded || disabled ? "inherit" : undefined} 39 | onClick={onClick} 40 | /> 41 | {transaction.date} 42 | 43 | {transaction.summary || transaction.reference} 44 | 45 | 46 | {transaction.value} 47 | 48 | 49 | 50 | ); 51 | }; 52 | 53 | const ContainerCollapse = styled(Collapse)({ gridColumnStart: "start", gridColumnEnd: "end" }); 54 | const ButtonBaseSx = { 55 | transfer: { 56 | display: "flex", 57 | alignItems: "center", 58 | marginBottom: 2, 59 | width: "max-content", 60 | padding: "0 5px", 61 | borderRadius: "2px", 62 | marginLeft: 28, 63 | }, 64 | disabled: { opacity: 0.5 }, 65 | excluded: { "&:not(:hover)": { opacity: 0.5 } }, 66 | }; 67 | const TransferButton = styled(Button)({ 68 | ...DIALOG_IMPORT_TABLE_ICON_BUTTON_STYLES, 69 | minWidth: 14, 70 | 71 | [`& .${svgIconClasses.root}`]: { 72 | fontSize: "14px !important", 73 | }, 74 | }); 75 | const TextTypography = styled(Typography)({ marginLeft: 20, maxWidth: 200 }); 76 | -------------------------------------------------------------------------------- /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/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/pages/summary/index.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import { ChevronRight } from "@mui/icons-material"; 3 | import { Button } from "@mui/material"; 4 | import React from "react"; 5 | import { Notifications } from "../../app/notifications"; 6 | import { FlexWidthChart } from "../../components/display/FlexWidthChart"; 7 | import { Page, Section, SECTION_MARGIN } from "../../components/layout"; 8 | import { 9 | BalanceSnapshotSummaryNumbers, 10 | TransactionSnapshotSummaryNumbers, 11 | useAssetsSnapshot, 12 | useGetSummaryChart, 13 | useTransactionsSnapshot, 14 | } from "../../components/snapshot"; 15 | import { OpenPageCache } from "../../state/app/actions"; 16 | import { PageStateType } from "../../state/app/pageTypes"; 17 | 18 | export const SummaryPage: React.FC = () => { 19 | const assetSummaryData = useAssetsSnapshot(); 20 | const transactionSummaryData = useTransactionsSnapshot(); 21 | 22 | const getAssetsChart = useGetSummaryChart(assetSummaryData); 23 | const getTransactionsChart = useGetSummaryChart(transactionSummaryData); 24 | 25 | return ( 26 | 27 | 28 | 29 |
}> 30 | 31 |
32 | 33 |
34 | 35 |
36 |
37 |
}> 38 | 39 |
40 | 41 |
42 | 43 |
44 |
45 |
46 |
47 | 48 |
49 |
50 |
51 | ); 52 | }; 53 | 54 | const SeeMore: React.FC<{ page: PageStateType["id"] }> = ({ page }) => ( 55 | 58 | ); 59 | 60 | const ContainerBox = styled("div")({ display: "flex" }); 61 | const SummaryColumnBox = styled("div")({ flexGrow: 1, marginRight: SECTION_MARGIN }); 62 | const SummaryContainer = styled("div")({ display: "flex", width: "100%", height: "100%" }); 63 | const NotificationColumnSx = { 64 | flexShrink: 0, 65 | alignSelf: "flex-start", 66 | 67 | // This is the hard-coded height of the other panels on the page 68 | // A proper solution was beyond my CSS skills 69 | maxHeight: 725, 70 | 71 | "& > div": { 72 | padding: 0, 73 | minHeight: 0, 74 | display: "flex", 75 | }, 76 | }; 77 | -------------------------------------------------------------------------------- /src/app/popups.tsx: -------------------------------------------------------------------------------- 1 | import { Close } from "@mui/icons-material"; 2 | import { Alert, AlertColor, Button, IconButton, Snackbar, SnackbarCloseReason } from "@mui/material"; 3 | import React, { createContext, useCallback, useContext, useEffect, useState } from "react"; 4 | import { FCWithChildren } from "../shared/types"; 5 | 6 | export interface PopupAlert { 7 | message: string; 8 | severity: AlertColor; 9 | duration?: number | null; 10 | action?: { 11 | name: string; 12 | callback: () => void; 13 | }; 14 | } 15 | 16 | export let setPopupAlert = (alert: PopupAlert) => undefined as void; 17 | 18 | const PopupContext = createContext(setPopupAlert); 19 | export const useSetAlert = () => useContext(PopupContext); 20 | 21 | export const PopupDisplay: FCWithChildren = ({ children }) => { 22 | const [alert, setAlert] = useState(); 23 | useEffect(() => void (setPopupAlert = (newAlert: PopupAlert) => setAlert(newAlert)), []); 24 | const close = useCallback(() => setAlert(undefined), []); 25 | const closeAll = useCallback((_: unknown, reason: SnackbarCloseReason) => { 26 | if (reason === "clickaway") return; 27 | setAlert(undefined); 28 | }, []); 29 | 30 | return ( 31 | <> 32 | 37 | {alert && ( 38 | 45 | {alert.action && ( 46 | 54 | )} 55 | {alert.duration !== null ? ( 56 | 57 | 58 | 59 | ) : undefined} 60 | 61 | ) : undefined 62 | } 63 | > 64 | {alert.message} 65 | 66 | )} 67 | 68 | {children} 69 | 70 | ); 71 | }; 72 | 73 | const getWrappedCallback = (callback: () => void, close: () => void) => () => { 74 | callback(); 75 | close(); 76 | }; 77 | -------------------------------------------------------------------------------- /src/dialog/settings/storage.tsx: -------------------------------------------------------------------------------- 1 | import { CheckCircle } from "@mui/icons-material"; 2 | import { Button, Card, CircularProgress, Typography } from "@mui/material"; 3 | import { Box } from "@mui/system"; 4 | import React from "react"; 5 | import { TopHatDispatch } from "../../state"; 6 | import { DataSlice } from "../../state/data"; 7 | import { useUserData } from "../../state/data/hooks"; 8 | import { redirectToDropboxAuthURI } from "../../state/logic/dropbox"; 9 | import { Greys, Intents } from "../../styles/colours"; 10 | import DropboxLogo from "./dropbox.svg"; 11 | import { SettingsDialogContents, SettingsDialogDivider, SettingsDialogPage } from "./shared"; 12 | 13 | export const DialogStorageContents: React.FC = () => { 14 | const config = useUserData((user) => user.dropbox); 15 | 16 | return ( 17 | 18 | 19 | TopHat can sync data to Dropbox, which enables copying data across computers and recovery in case of 20 | problems. This is strictly optional, and will only run after an account is connected and the app is 21 | online. 22 | 23 | 24 | 25 | img:first-of-type": { 34 | width: 150, 35 | padding: "16px 0", 36 | }, 37 | "& > button": { 38 | marginTop: 10, 39 | }, 40 | }} 41 | > 42 | 43 | {config === "loading" ? ( 44 | 45 | ) : config ? ( 46 | <> 47 | 48 | 49 | {config.name} 50 | 51 | 52 | 53 | 54 | {config.email} 55 | 56 | 57 | 58 | ) : ( 59 | 62 | )} 63 | 64 | 65 | 66 | ); 67 | }; 68 | 69 | const removeDropboxSync = () => TopHatDispatch(DataSlice.actions.removeDropoxSync()); 70 | -------------------------------------------------------------------------------- /src/components/table/filters/RangeFilters.tsx: -------------------------------------------------------------------------------- 1 | import { Slider, SliderProps } from "@mui/material"; 2 | import { DateTime } from "luxon"; 3 | import React, { useCallback, useMemo } from "react"; 4 | import { formatNumber } from "../../../shared/data"; 5 | import { useFirstValue } from "../../../shared/hooks"; 6 | import { formatDate, getNow, parseDate, SDate } from "../../../state/shared/values"; 7 | 8 | interface DateRangeFilterProps { 9 | min?: SDate; 10 | max?: SDate; 11 | from?: SDate; 12 | to?: SDate; 13 | setRange: (from: string | undefined, to: string | undefined) => void; 14 | } 15 | export const DateRangeFilter: React.FC = ({ min, max, from: rawFrom, to: rawTo, setRange }) => { 16 | const { range, values, onChange } = useMemo(() => { 17 | const start = min ? parseDate(min) : getNow(); 18 | const getNumericValue = (date: DateTime) => date.diff(start, "days").days; 19 | const range = Math.floor(getNumericValue(max ? parseDate(max) : getNow())); 20 | const values = [ 21 | rawFrom ? getNumericValue(parseDate(rawFrom)) : 0, 22 | rawTo ? getNumericValue(parseDate(rawTo)) : range, 23 | ]; 24 | 25 | const onChange = (_: any, value: [number, number]) => { 26 | const from = value[0] === 0 ? undefined : formatDate(start.plus({ days: value[0] })); 27 | const to = value[1] === range ? undefined : formatDate(start.plus({ days: Math.floor(value[1]) })); 28 | 29 | if (from !== rawFrom || to !== rawTo) { 30 | setRange(from, to); 31 | } 32 | }; 33 | 34 | return { range, values, onChange }; 35 | }, [max, min, rawFrom, rawTo, setRange]); 36 | 37 | // Defaults shouldn't be changed after the component is initialised. 38 | const defaults = useFirstValue(values); 39 | 40 | return ( 41 | 42 | ); 43 | }; 44 | 45 | interface NumericRangeFilterProps { 46 | min?: number; 47 | max?: number; 48 | from?: number; 49 | to?: number; 50 | setRange: (from: number | undefined, to: number | undefined) => void; 51 | } 52 | export const NumericRangeFilter: React.FC = ({ min, max, from, to, setRange }) => { 53 | const onChange = useCallback( 54 | (_: any, values: [number, number]) => { 55 | const newFrom = values[0] === min ? undefined : values[0]; 56 | const newTo = values[1] === max ? undefined : values[1]; 57 | 58 | if (from !== newFrom || to !== newTo) { 59 | setRange(newFrom, newTo); 60 | } 61 | }, 62 | [max, min, from, to, setRange] 63 | ); 64 | 65 | // Defaults shouldn't be changed after the component is initialised. 66 | const defaults = useFirstValue([from || min || 0, to || max || 0]); 67 | 68 | return ( 69 | 77 | ); 78 | }; 79 | 80 | const formatLarge = (value: number) => formatNumber(value, { end: "k", maxDecimals: 0 }); 81 | -------------------------------------------------------------------------------- /src/pages/accounts/table/institution.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import { AccountBalance } from "@mui/icons-material"; 3 | import { Avatar, Button, Card, Typography } from "@mui/material"; 4 | import { Box } from "@mui/system"; 5 | import React, { useCallback } from "react"; 6 | import { TopHatDispatch } from "../../../state"; 7 | import { AppSlice } from "../../../state/app"; 8 | import { PLACEHOLDER_INSTITUTION_ID } from "../../../state/data"; 9 | import { Greys } from "../../../styles/colours"; 10 | import { AccountTableEntry } from "./account"; 11 | import { AccountsInstitutionSummary } from "./data"; 12 | import { AccountsTableAccountsBox, AccountsTableIconSx, AccountsTableInstitutionBox } from "./styles"; 13 | 14 | const IconAvatar = styled(Avatar)(AccountsTableIconSx); 15 | 16 | export const AccountsInstitutionDisplay: React.FC<{ institution: AccountsInstitutionSummary }> = ({ institution }) => { 17 | const onEditInstitution = useCallback( 18 | () => TopHatDispatch(AppSlice.actions.setDialogPartial({ id: "institution", institution })), 19 | [institution] 20 | ); 21 | 22 | return ( 23 | 24 | 25 | 26 | 27 | 28 | 33 | {institution.name} 34 | 35 | 41 | EDIT 42 | 43 | 44 | 45 | 46 | {institution.accounts.map((account) => ( 47 | 48 | ))} 49 | 50 | 51 | ); 52 | }; 53 | 54 | const ContainerCard = styled(Card)({ 55 | display: "flex", 56 | alignItems: "flex-start", 57 | position: "relative", 58 | marginTop: 27, 59 | }); 60 | const InstitutionColourSquareBox = styled(Box)({ 61 | position: "absolute", 62 | width: 320, 63 | height: 280, 64 | left: -37.66, 65 | top: -86.53, 66 | opacity: 0.1, 67 | borderRadius: "48px", 68 | transform: "rotate(-60deg)", 69 | pointerEvents: "none", 70 | }); 71 | const InstitutionNameTypography = styled(Typography)({ 72 | lineHeight: 1, 73 | marginTop: 2, 74 | width: "100%", 75 | }); 76 | const PlaceholderInstitutionNameSx = { 77 | fontStyle: "italic", 78 | color: Greys[500], 79 | }; 80 | const InstitutionEditActionButton = styled(Button)({ 81 | color: Greys[600], 82 | height: 20, 83 | minWidth: 40, 84 | marginTop: 2, 85 | marginLeft: -5, 86 | }); 87 | -------------------------------------------------------------------------------- /src/pages/category/index.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import { FormControlLabel, Switch } from "@mui/material"; 3 | import { useMemo } from "react"; 4 | import { Page, SECTION_MARGIN } from "../../components/layout"; 5 | import { TransactionsTable } from "../../components/table"; 6 | import { 7 | TransactionsTableFilters, 8 | TransactionsTableFixedDataState, 9 | TransactionsTableState, 10 | } from "../../components/table/table/types"; 11 | import { TopHatDispatch } from "../../state"; 12 | import { AppSlice } from "../../state/app"; 13 | import { useCategoryPageCategory, useCategoryPageState } from "../../state/app/hooks"; 14 | import { useAllCategories } from "../../state/data/hooks"; 15 | import { CategoryPageBudgetSummary } from "./budget"; 16 | import { CategoryPageHeader } from "./header"; 17 | import { CategoryPageHistory } from "./history"; 18 | 19 | const MiddleBox = styled("div")({ 20 | display: "flex", 21 | "& > div:first-of-type": { 22 | flex: "2 0 700px", 23 | marginRight: SECTION_MARGIN, 24 | }, 25 | "& > div:last-child": { 26 | flex: "1 1 300px", 27 | }, 28 | }); 29 | 30 | export const CategoryPage: React.FC = () => { 31 | const category = useCategoryPageCategory(); 32 | const table = useCategoryPageState((state) => state.table); 33 | 34 | const id = category?.id ?? -1; // Continue hooks in case Account is deleted while on page 35 | const hasChildren = useAllCategories().some(({ hierarchy }) => hierarchy.includes(id)); 36 | 37 | // "table" is only undefined when redirecting to AccountsPage after deletion 38 | const fixed: TransactionsTableFixedDataState = useMemo( 39 | () => ({ type: "category", category: id, nested: table?.nested && hasChildren }), 40 | [id, table?.nested, hasChildren] 41 | ); 42 | 43 | // In case Category is deleted while on page 44 | if (!category) { 45 | TopHatDispatch(AppSlice.actions.setPage("categories")); 46 | return ; 47 | } 48 | 49 | return ( 50 | 51 | 52 | 53 | 54 | 55 | 56 | } 66 | label="Include Subcategories" 67 | /> 68 | ) : undefined 69 | } 70 | /> 71 | 72 | ); 73 | }; 74 | 75 | const setFilters = (filters: TransactionsTableFilters) => 76 | TopHatDispatch(AppSlice.actions.setCategoryTableStatePartial({ filters })); 77 | 78 | const setState = (state: TransactionsTableState) => 79 | TopHatDispatch(AppSlice.actions.setCategoryTableStatePartial({ state })); 80 | 81 | const handleToggle = (event: React.ChangeEvent) => 82 | TopHatDispatch(AppSlice.actions.setCategoryTableStatePartial({ nested: event.target.checked })); 83 | -------------------------------------------------------------------------------- /src/dialog/header.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import { 3 | AccountBalance, 4 | AccountBalanceWallet, 5 | CallSplit, 6 | Clear, 7 | Description, 8 | Euro, 9 | NoteAdd, 10 | Settings, 11 | ShoppingBasket, 12 | } from "@mui/icons-material"; 13 | import { 14 | Divider, 15 | IconButton, 16 | ListItemIcon, 17 | ListItemText, 18 | MenuItem, 19 | outlinedInputClasses, 20 | Select, 21 | selectClasses, 22 | } from "@mui/material"; 23 | import { handleSelectChange } from "../shared/events"; 24 | import { IconType } from "../shared/types"; 25 | import { TopHatDispatch } from "../state"; 26 | import { AppSlice, DialogState } from "../state/app"; 27 | import { useDialogPage } from "../state/app/hooks"; 28 | import { DIALOG_OPTIONS_WIDTH } from "./shared"; 29 | 30 | export const DialogHeader: React.FC = () => { 31 | const state = useDialogPage(); 32 | 33 | return ( 34 | 35 | 43 | 44 | 45 | 46 | 47 | ); 48 | }; 49 | 50 | export const closeDialogBox = () => TopHatDispatch(AppSlice.actions.setDialogPage("closed")); 51 | const changeDialogScreen = handleSelectChange((id: DialogState["id"]) => { 52 | TopHatDispatch(AppSlice.actions.setDialogPage(id)); 53 | setTimeout(() => (document.activeElement as HTMLElement | undefined)?.blur()); 54 | }); 55 | 56 | const ExpandedListItemText = styled(ListItemText)({ marginTop: "4px !important", marginBottom: "4px !important" }); 57 | const getMenuItem = (Icon: IconType, name: string, display: string) => ( 58 | 59 | 60 | 61 | 62 | {display} 63 | 64 | ); 65 | const MenuItems = [ 66 | getMenuItem(AccountBalanceWallet, "account", "Accounts"), 67 | getMenuItem(AccountBalance, "institution", "Institutions"), 68 | getMenuItem(ShoppingBasket, "category", "Categories"), 69 | getMenuItem(Euro, "currency", "Currencies"), 70 | , 71 | getMenuItem(Description, "statement", "Statements"), 72 | getMenuItem(NoteAdd, "import", "Statement Import"), 73 | getMenuItem(CallSplit, "rule", "Rules"), 74 | , 75 | getMenuItem(Settings, "settings", "Settings"), 76 | ]; 77 | 78 | const HeaderBox = styled("div")({ 79 | height: 60, 80 | padding: "3px 8px 3px 20px", 81 | display: "flex", 82 | alignItems: "center", 83 | justifyContent: "space-between", 84 | flexShrink: 0, 85 | 86 | [`& .${outlinedInputClasses.root}:not(:hover):not(:focus-within) .${outlinedInputClasses.notchedOutline}`]: { 87 | border: "none", 88 | }, 89 | 90 | [`& .${selectClasses.select}`]: { 91 | width: DIALOG_OPTIONS_WIDTH - 32 - 18 - 20 * 2, 92 | display: "flex", 93 | alignItems: "center", 94 | padding: "5px 32px 5px 18px", 95 | }, 96 | }); 97 | const CloseIconButton = styled(IconButton)({ marginRight: 10 }); 98 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TopHat 2 | 3 | TopHat is an offline-first personal finances app. It is designed to let users manage their finances across multiple currencies, in a privacy-preserving way. 4 | 5 | ![Transactions View](screenshot.png) 6 | 7 | ### What is it, technically speaking? 8 | 9 | It's a web app served by GitHub Pages, with no backend service. 10 | 11 | The frontend is a Single Page App bootstrapped with Create-React-App, built using Typescript/React/Redux and some other common libraries (primarily [Victory Charts](https://formidable.com/open-source/victory/) and [Material-UI](https://mui.com/)). It uses a Service Worker for offline behaviour, and IndexedDB (via dexie.js) for storage. 12 | 13 | ### Why have you done this? 14 | 15 | I know, another personal finance app. 16 | 17 | I wanted to track my expenses in a lightweight way, without doing something crazy like giving a 3rd party the passwords to my bank accounts. After looking around, I found that I wanted three main things: 18 | 19 | - Privacy: I don't want my credentials or account details to be sold or used for ad targeting, or subject to the questionable infosec standards of the latest hip FinTech startup. This also rules out direct connections to most banks, so I need a smooth and automated experience for uploading bank statements, to save the manual gruntwork. 20 | - Multi-Currency Support: I hold money in multiple currencies, and need to be able to track them over time. This is strangely uncommon in the world of personal finance apps. 21 | - Transaction Tracking: I don't need a full YNAB-style budgeting workflow, but I do want to be able to track how much I'm spending on bills/recreation/travel over time, and see how the balance between them is changing. 22 | 23 | I couldn't find anything which hit all three requirements, so after many weekends of work, TopHat is now what I'm using. 24 | 25 | ### Should I use this? 26 | 27 | Probably not! The goals of TopHat are fairly niche interests, and there may be bugs that I'm not seeing. That said, I can at least vouch for the fact that I'm using it, and that I see no reason why I would take it down in the future. It's available publicly at [https://athenodoros.github.io/TopHat](https://athenodoros.github.io/TopHat), and it can populate itself with notional data for an easy trial. 28 | 29 | ## Potentially Asked Questions 30 | 31 | **Why have you done this in the browser? Why not a native app?** 32 | 33 | My feelings are basically summarised in [this XKCD](https://xkcd.com/1367/), but the short answer is that deployment, installation, and upgrades are all made extremely easy in the browser. It can be [installed as a Progressive Web App](https://support.google.com/chrome/answer/9658361), if something app-shaped is desired. 34 | 35 | A reasonable concern might be around performance, particularly given that all data is loaded in memory while the app is open. In practice, my experience is that this isn't a major issue even with thousands of transactions, so I haven't spent time on a "more scalable" architecture. 36 | 37 | **Does this work on mobile?** 38 | 39 | It does not: TopHat is designed for periodic updates and inspection, rather than the daily use which would suggest a mobile interface. Mostly this is because I dislike the idea of constant monitoring of finances, but it would also be difficult to sync data between computer and mobile without a backend server. 40 | 41 | **I found a bug/I have a cool idea/I want to say hi!** 42 | 43 | Let me know! No promises, though, although I'll probably say hi back. 44 | -------------------------------------------------------------------------------- /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/dialog/objects/shared/draggable.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import { Menu } from "@mui/icons-material"; 3 | import { List, MenuItem } from "@mui/material"; 4 | import React from "react"; 5 | import { 6 | DragDropContext, 7 | Draggable, 8 | DraggableProvided, 9 | DraggableStateSnapshot, 10 | Droppable, 11 | DroppableProvided, 12 | DropResult, 13 | } from "react-beautiful-dnd"; 14 | import { withSuppressEvent } from "../../../shared/events"; 15 | import { useDialogState } from "../../../state/app/hooks"; 16 | import { BasicObjectType } from "../../../state/data/types"; 17 | import { Greys } from "../../../styles/colours"; 18 | import { DialogOptions } from "../../shared/layout"; 19 | import { 20 | DialogObjectOptionsBox, 21 | DialogObjectSelectorProps, 22 | DialogSelectorAddNewButton, 23 | useObjectsWithExclusionList, 24 | } from "./shared"; 25 | import { getUpdateFunctions } from "./update"; 26 | export { ObjectEditContainer } from "./edit"; 27 | export { getUpdateFunctions } from "./update"; 28 | 29 | export const DraggableDialogObjectSelector = ({ 30 | type, 31 | exclude, 32 | createDefaultOption, 33 | onAddNew, 34 | render, 35 | onDragEnd, 36 | }: DialogObjectSelectorProps & { onDragEnd: (result: DropResult) => void }) => { 37 | const selected = useDialogState(type, (object) => object?.id); 38 | const options = useObjectsWithExclusionList(type, exclude); 39 | const functions = getUpdateFunctions(type); 40 | 41 | const getMenuItem = (option: BasicObjectType[Name]) => ( 42 | 43 | {(provided: DraggableProvided, snapshot: DraggableStateSnapshot) => ( 44 | (() => functions.set(option))} 48 | {...provided.draggableProps} 49 | ref={provided.innerRef} 50 | > 51 | {render(option)} 52 | {provided && ( 53 | 54 | 55 | 56 | )} 57 | 58 | )} 59 | 60 | ); 61 | 62 | const getList = (provided: DroppableProvided) => ( 63 | 64 | {options.map(getMenuItem)} 65 | {provided.placeholder} 66 | 67 | ); 68 | 69 | return ( 70 | 71 | 72 | 73 | {getList} 74 | 75 | 76 | {(createDefaultOption || onAddNew) && ( 77 | (onAddNew ? onAddNew() : functions.set(createDefaultOption!()))} 79 | type={type} 80 | /> 81 | )} 82 | 83 | ); 84 | }; 85 | const HandleBox = styled("div")({ display: "flex", alignItems: "center", marginRight: 5 }); 86 | -------------------------------------------------------------------------------- /src/components/snapshot/data.tsx: -------------------------------------------------------------------------------- 1 | import { range, sum, toPairs, unzip, zip } from "lodash"; 2 | import { useMemo } from "react"; 3 | import { equalZip, takeWithDefault } from "../../shared/data"; 4 | import { useAllAccounts, useAllCategories } from "../../state/data/hooks"; 5 | import { TRANSFER_CATEGORY_ID } from "../../state/data/shared"; 6 | import { ID } from "../../state/shared/values"; 7 | 8 | export interface SnapshotSectionData { 9 | trends: { 10 | credits: number[]; 11 | debits: number[]; 12 | }; 13 | net: number[]; 14 | currency?: ID; 15 | } 16 | 17 | export const useAssetsSnapshot = (account?: ID, currency?: ID) => { 18 | const accounts = useAllAccounts(); 19 | 20 | return useMemo( 21 | () => 22 | getSnapshotDisplayValues( 23 | accounts 24 | .filter(({ id }) => account === undefined || id === account) 25 | .flatMap(({ balances }) => 26 | toPairs(balances) 27 | .filter(([id, _]) => currency === undefined || currency === Number(id)) 28 | .map(([_, balance]) => balance[currency === undefined ? "localised" : "original"]) 29 | ) 30 | .reduce( 31 | (accs, balances) => 32 | zip(accs, balances).map(([acc, bal]) => { 33 | const [pos, neg] = acc || ([0, 0] as [number, number]); 34 | return (bal && bal > 0 ? [pos + bal, neg] : [pos, neg + (bal || 0)]) as [ 35 | number, 36 | number 37 | ]; 38 | }), 39 | [] as [number, number][] 40 | ), 41 | currency 42 | ), 43 | [accounts, account, currency] 44 | ); 45 | }; 46 | 47 | export const useTransactionsSnapshot = (category?: ID): SnapshotSectionData => { 48 | const categories = useAllCategories(); 49 | 50 | return useMemo( 51 | () => 52 | getSnapshotDisplayValues( 53 | unzip( 54 | categories 55 | .filter(({ id }) => id !== TRANSFER_CATEGORY_ID) 56 | .filter(({ id }) => category === undefined || id === category) 57 | .flatMap(({ transactions }) => transactions) 58 | .reduce( 59 | ([accCredits, accDebits], { credits, debits }) => 60 | [ 61 | zip(accCredits, credits).map(([acc, val]) => (acc || 0) + (val || 0)), 62 | zip(accDebits, debits).map(([acc, val]) => (acc || 0) + (val || 0)), 63 | ] as [number[], number[]], 64 | [[], []] as [number[], number[]] 65 | ) 66 | ) as [number, number][] 67 | ), 68 | [categories, category] 69 | ); 70 | }; 71 | 72 | const getSnapshotDisplayValues = (trends: [number, number][], currency?: ID) => { 73 | let [credits, debits] = trends.length ? unzip(trends) : [range(12).map((_) => 0), range(12).map((_) => 0)]; 74 | credits = takeWithDefault(credits, 25, 0); 75 | debits = takeWithDefault(debits, 25, 0); 76 | 77 | const net = equalZip(credits, debits).map(sum); 78 | return { trends: { credits, debits }, net, currency }; 79 | }; 80 | -------------------------------------------------------------------------------- /src/app/context.tsx: -------------------------------------------------------------------------------- 1 | import { CssBaseline, StyledEngineProvider, ThemeProvider } from "@mui/material"; 2 | import { LocalizationProvider } from "@mui/x-date-pickers"; 3 | import { AdapterLuxon } from "@mui/x-date-pickers/AdapterLuxon"; 4 | import { noop, omit } from "lodash-es"; 5 | import React from "react"; 6 | import { FileRejection, useDropzone } from "react-dropzone"; 7 | import { Provider } from "react-redux"; 8 | import { TopHatDialog } from "../dialog"; 9 | import { FCWithChildren } from "../shared/types"; 10 | import { TopHatStore } from "../state"; 11 | import { handleStatementFileUpload } from "../state/logic/statement"; 12 | import { TopHatTheme } from "../styles/theme"; 13 | import { PageErrorBoundary } from "./error"; 14 | import { PopupDisplay } from "./popups"; 15 | import { TopHatTutorial } from "./tutorial"; 16 | 17 | export const FileHandlerContext = React.createContext<{ 18 | openFileDialog: () => void; 19 | acceptedFiles: File[]; 20 | fileRejections: FileRejection[]; 21 | isDragActive: boolean; 22 | dropzoneRef: React.RefObject | null; 23 | }>({ 24 | openFileDialog: noop, 25 | acceptedFiles: [], 26 | fileRejections: [], 27 | isDragActive: false, 28 | dropzoneRef: null, 29 | }); 30 | 31 | export const TopHatContextProvider: FCWithChildren = ({ children }) => { 32 | const { 33 | open: openFileDialog, 34 | acceptedFiles, 35 | fileRejections, 36 | getRootProps, 37 | getInputProps, 38 | isDragActive, 39 | rootRef: dropzoneRef, 40 | } = useDropzone({ 41 | accept: { "text/csv": [".csv"] }, 42 | onDrop: handleStatementFileUpload, 43 | }); 44 | 45 | return ( 46 | <> 47 | 48 | 49 | 50 | 51 | 52 | 53 | 62 | 63 |
64 | 65 | 66 | 70 | {children} 71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 | 80 | ); 81 | }; 82 | -------------------------------------------------------------------------------- /src/pages/forecasts/data.tsx: -------------------------------------------------------------------------------- 1 | import { dropRightWhile, identity, mapValues, mean, range, sum, sumBy, unzip, values } from "lodash"; 2 | import { TopHatStore } from "../../state"; 3 | import { Account, getDateBucket } from "../../state/data"; 4 | import { formatDate, getNow } from "../../state/shared/values"; 5 | 6 | const getAccountBalances = (account: Account) => 7 | unzip(values(account.balances).map((balance) => balance.localised)).map((values) => sum(values.filter(identity))); 8 | const getAccountBalance = (account: Account) => getAccountBalances(account)[0]; 9 | const getDataState = () => TopHatStore.getState().data; 10 | const getAccounts = () => { 11 | const { account } = getDataState(); 12 | return account.ids.map((id) => account.entities[id]!); 13 | }; 14 | const sumAccounts = (getValue: (account: Account) => number) => sumBy(getAccounts(), getValue); 15 | const sample = (values: number[]) => mean(range(1, 4).map((i) => values[i] || 0)); 16 | 17 | export const CalculatorEstimates = { 18 | constant: (value: number) => () => value, 19 | netWorth: () => sumAccounts(getAccountBalance), 20 | debt: () => { 21 | const debtAccounts = getAccounts().filter((account) => getAccountBalance(account) < 0); 22 | return -sumBy(debtAccounts, getAccountBalance); 23 | }, 24 | repayments: () => { 25 | const repayments = range(12).map((_) => 0); 26 | const previous = formatDate(getNow().startOf("month").minus({ months: 1 })); 27 | 28 | const accounts = mapValues(getDataState().account.entities, (account) => getAccountBalances(account!)); 29 | 30 | const transactions = getDataState().transaction.entities; 31 | for (let id of getDataState().transaction.ids) { 32 | const tx = transactions[id]!; 33 | const bucket = getDateBucket(tx.date, previous); 34 | if (bucket < 0) continue; 35 | if (bucket > 11) break; 36 | 37 | if (tx.value! > 0 && accounts[tx.account][bucket] < 0) { 38 | repayments[bucket] += tx.value!; 39 | } 40 | } 41 | 42 | return mean(dropRightWhile(repayments, (x) => !x)) || 0; 43 | }, 44 | income: () => sumAccounts(({ transactions }) => sample(transactions.credits)), 45 | expenses: () => sumAccounts(({ transactions }) => -sample(transactions.debits)), 46 | savings: () => sumAccounts(({ transactions }) => sample(transactions.credits) + sample(transactions.debits)), 47 | interest: () => { 48 | // Estimate negative interest by looking at negative transactions in debt accounts in the previous 12 months 49 | // This works reasonably well for a cost-averaged rate on large loans (eg. mortgages) 50 | // When the transactions might actually be payments (eg. an overdrawn transaction account) this 51 | // doesn't estimate the actual interest rate, but still produces a reasonable number for calculations 52 | let debtIncreasingTransactionTotal = 0; 53 | let negativeBalanceTotals = 0; 54 | getAccounts().forEach((account) => { 55 | const balances = getAccountBalances(account); 56 | const { debits } = account.transactions; 57 | 58 | range(1, 13).forEach((i) => { 59 | if (balances[i] < 0 && debits[i] !== undefined) { 60 | debtIncreasingTransactionTotal += debits[i]; 61 | negativeBalanceTotals += balances[i]; 62 | } 63 | }); 64 | }); 65 | return negativeBalanceTotals ? (debtIncreasingTransactionTotal / (negativeBalanceTotals / 12)) * 100 : 3; 66 | }, 67 | }; 68 | -------------------------------------------------------------------------------- /src/dialog/settings/dropbox.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 11 | 13 | 25 | 26 | 27 | 28 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /src/dialog/import/steps/final.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Checkbox, StepContent, Tooltip, Typography } from "@mui/material"; 2 | import { useState } from "react"; 3 | import { handleCheckboxChange } from "../../../shared/events"; 4 | import { TopHatDispatch, TopHatStore } from "../../../state"; 5 | import { AppSlice } from "../../../state/app"; 6 | import { useDialogState } from "../../../state/app/hooks"; 7 | import { DialogStatementImportState } from "../../../state/app/statementTypes"; 8 | import { 9 | canImportStatementsAndClearDialog, 10 | goBackToStatementMapping, 11 | importStatementsAndClearDialog, 12 | } from "../../../state/logic/statement"; 13 | import { DialogImportActionsBox, DialogImportOptionBox, DialogImportOptionsContainerBox } from "./shared"; 14 | 15 | export const DialogImportImportStepContent: React.FC<{ 16 | shouldDetectTransfers: boolean; 17 | setShouldDetectTransfers: (value: boolean) => void; 18 | }> = ({ shouldDetectTransfers, setShouldDetectTransfers }) => { 19 | const [shouldRunRules, setShouldRunRules] = useState(true); 20 | const reversed = useDialogState("import", (state) => (state as DialogStatementImportState).reverse); 21 | 22 | return ( 23 | 24 | 25 | 26 | Include Transfers 27 | 33 | 34 | 35 | Run Import Rules 36 | 42 | 43 | 44 | Reverse Transaction Order 45 | 46 | 47 | 48 | 49 | 52 | 53 |
54 | 62 |
63 |
64 |
65 |
66 | ); 67 | }; 68 | 69 | const toggleReverseOrder = handleCheckboxChange((reverse) => { 70 | const current = TopHatStore.getState().app.dialog.import as DialogStatementImportState; 71 | TopHatDispatch( 72 | AppSlice.actions.setDialogPartial({ 73 | id: "import", 74 | import: { 75 | ...current, 76 | reverse, 77 | }, 78 | }) 79 | ); 80 | }); 81 | -------------------------------------------------------------------------------- /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/components/display/BasicBarChart.tsx: -------------------------------------------------------------------------------- 1 | import { lighten, Tooltip } from "@mui/material"; 2 | import { Box, SxProps } from "@mui/system"; 3 | import { identity } from "lodash"; 4 | import React from "react"; 5 | import { getChartDomainFunctions } from "../../shared/data"; 6 | import { Greys, Intents } from "../../styles/colours"; 7 | import { getThemeTransition } from "../../styles/theme"; 8 | 9 | export const getBasicBarChartColour = (success: boolean | null, stub?: boolean) => 10 | success === null 11 | ? stub 12 | ? { main: Greys[700], dark: Greys[700] } 13 | : Intents.primary 14 | : Intents[success ? "success" : "danger"]; 15 | 16 | export const BasicBarChart: React.FC<{ 17 | className?: string; 18 | getMouseOverText?: (value: number) => string; 19 | values: number[]; 20 | selected?: number; 21 | setSelected?: (index: number) => void; 22 | sx?: SxProps; 23 | }> = ({ className, getMouseOverText, sx, values, selected: selectedIndex, setSelected }) => { 24 | const { getPoint, getOffsetAndSizeForRange } = getChartDomainFunctions(values); 25 | const width = (1 / values.length) * 100 + "%"; 26 | 27 | const getColour = (value: number) => 28 | getBasicBarChartColour( 29 | values.some((x) => x < 0) && values.some((x) => x >= 0) ? value >= 0 : null, 30 | !values.some(identity) 31 | ); 32 | 33 | return ( 34 | 35 | {values.map((value, idx) => { 36 | const colour = getColour(value); 37 | const selected = selectedIndex === idx; 38 | const { offset: bottom, size: height } = getOffsetAndSizeForRange(value, 0); 39 | const right = (idx / values.length) * 100 + "%"; 40 | const common = { 41 | position: "absolute" as const, 42 | right, 43 | width, 44 | transition: getThemeTransition("all"), 45 | }; 46 | 47 | return ( 48 | 49 | 50 |
setSelected(idx))} 59 | /> 60 | 61 |
71 |
81 | 82 | ); 83 | })} 84 | 85 | ); 86 | }; 87 | -------------------------------------------------------------------------------- /src/dialog/import/steps/parse.tsx: -------------------------------------------------------------------------------- 1 | import { Help } from "@mui/icons-material"; 2 | import { Button, Checkbox, IconButton, StepContent, Tooltip, Typography } from "@mui/material"; 3 | import { handleTextFieldChange } from "../../../shared/events"; 4 | import { DialogStatementParseState } from "../../../state/app/statementTypes"; 5 | import { 6 | canGoToStatementMappingScreen, 7 | changeStatementParsing, 8 | goToStatementMappingScreen, 9 | removeAllStatementFiles, 10 | toggleStatementHasHeader, 11 | } from "../../../state/logic/statement"; 12 | import { Greys } from "../../../styles/colours"; 13 | import { 14 | DialogImportActionsBox, 15 | DialogImportInputTextField, 16 | DialogImportOptionBox, 17 | DialogImportOptionsContainerBox, 18 | DialogImportOptionTitleContainerBox, 19 | } from "./shared"; 20 | 21 | export const DialogImportParseStepContent: React.FC<{ state: DialogStatementParseState }> = ({ state }) => ( 22 | 23 | 24 | 25 | Header Row 26 | 32 | 33 | 34 | Delimiter 35 | 42 | 43 | 44 | 45 | Date Format 46 | 47 | 52 | 53 | 54 | 55 | 56 | 63 | 64 | 65 | 66 | 69 | 70 |
71 | 79 |
80 |
81 |
82 |
83 | ); 84 | 85 | const changeDelimiter = handleTextFieldChange((value) => changeStatementParsing({ delimiter: value || undefined })); 86 | const changeDateFormat = handleTextFieldChange((value) => changeStatementParsing({ dateFormat: value || undefined })); 87 | -------------------------------------------------------------------------------- /src/components/inputs/values.tsx: -------------------------------------------------------------------------------- 1 | import { Checkbox, FormControlLabel } from "@mui/material"; 2 | import { SxProps } from "@mui/system"; 3 | import { DatePicker, DatePickerProps } from "@mui/x-date-pickers"; 4 | import { noop } from "lodash"; 5 | import { DateTime } from "luxon"; 6 | import { useCallback, useEffect, useState } from "react"; 7 | import { SDate, formatDate, getNow, parseDate } from "../../state/shared/values"; 8 | 9 | interface SubItemCheckboxProps { 10 | label: string; 11 | checked: boolean; 12 | setChecked: (value: boolean) => void; 13 | left?: boolean; 14 | sx?: SxProps; 15 | disabled?: boolean; 16 | } 17 | export const SubItemCheckbox: React.FC = ({ label, checked, setChecked, left, sx, disabled }) => ( 18 | setChecked(!checked)} 38 | /> 39 | } 40 | label={label} 41 | labelPlacement={left ? "end" : "start"} 42 | disabled={disabled} 43 | /> 44 | ); 45 | 46 | export const ManagedDatePicker = ({ 47 | value: initial, 48 | onChange, 49 | nullable, 50 | maxDate, 51 | minDate, 52 | disableFuture, 53 | disablePast, 54 | ...props 55 | }: Omit>, "value" | "onChange"> & { 56 | value: Nullable extends true ? SDate | undefined : SDate; 57 | onChange: (value: Nullable extends true ? SDate | undefined : SDate) => void; 58 | nullable: Nullable; 59 | }) => { 60 | const [value, setValue] = useState(parseDate(initial) || null); 61 | useEffect(() => setValue(parseDate(initial || null)), [initial]); 62 | 63 | const onChangeHandler = useCallback>["onChange"]>>( 64 | // Either called with null (empty), an invalid DateTime, or a valid DateTime 65 | (newValue: DateTime | null, _context: any) => { 66 | setValue(newValue); 67 | 68 | if (nullable && newValue === null) return (onChange as any)(undefined); 69 | if ( 70 | newValue && 71 | (newValue as DateTime).isValid && 72 | (!minDate || (minDate as DateTime) <= (newValue as DateTime)) && 73 | (!maxDate || (maxDate as DateTime) >= (newValue as DateTime)) && 74 | (!disableFuture || (newValue as DateTime) <= getNow()) && 75 | (!disablePast || (newValue as DateTime) >= getNow()) 76 | ) 77 | return onChange(formatDate(newValue as DateTime)); 78 | }, 79 | [nullable, onChange, minDate, maxDate, disableFuture, disablePast] 80 | ); 81 | 82 | return ( 83 | 100 | ); 101 | }; 102 | --------------------------------------------------------------------------------