Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@

node_modules

.idea

# Swap the comments on the following lines if you wish to use zero-installs
# In that case, don't forget to run `yarn config set enableGlobalCache false`!
# Documentation here: https://yarnpkg.com/features/caching#zero-installs

#!.yarn/cache
.pnp.*

coverage
coverage
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

[![codecov](https://codecov.io/gh/LucaDiba/monyfox/graph/badge.svg?token=1PDRWAPU6X)](https://codecov.io/gh/LucaDiba/monyfox)

Welcome to MonyFox, your comprehensive open-source solution for managing your finances efficiently. MonyFox is a web app that works 100% locally. It is designed to help you keep track of your money, visualize your financial data with insightful charts, and plan for a better financial future.
Welcome to MonyFox, your comprehensive open-source solution for managing your finances efficiently. MonyFox is a web app
that works 100% locally. It is designed to help you keep track of your money, visualize your financial data with
insightful charts, and plan for a better financial future.

![MonyFox dashboard screenshot](./images/dashboard.png)

Expand All @@ -13,6 +15,7 @@ Welcome to MonyFox, your comprehensive open-source solution for managing your fi
- 💻 **User-Friendly Interface:** Intuitive design for seamless navigation and usage.
- 💶 **Multi-Currency Support:** Manage your finances in multiple currencies with ease.
- 📈 **Stock Tracking:** Monitor your investments and track stock performance.
- 📂 **Data Import:** Import financial data from various sources for a unified view.
- 💾 **Backup and Restore:** Easily backup and restore your financial data.
- 🌐 **100% Open Source:** Fully open-source, ensuring transparency and customization options.
- 🏠 **100% Local:** All data is stored locally on your device, ensuring privacy and security.
Expand All @@ -21,9 +24,9 @@ Welcome to MonyFox, your comprehensive open-source solution for managing your fi

- 💳 **Budgeting Tools:** Set budgets and track your spending to stay on target.
- 📜 **Debt Management:** Track your debts and payoff plans.
- 📂 **Data Import:** Import financial data from various sources for a unified view.
- 📱 **Mobile App:** Access MonyFox on the go with a dedicated PWA.
- 🌐 **Sync Across Devices:** Sync your financial data across multiple devices for seamless access. The data will be encrypted and stored in a secure cloud service.
- 🌐 **Sync Across Devices:** Sync your financial data across multiple devices for seamless access. The data will be
encrypted and stored in a secure cloud service.

## Getting Started

Expand Down
1 change: 1 addition & 0 deletions apps/client/dashboard/.prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
5 changes: 5 additions & 0 deletions apps/client/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"@formkit/auto-animate": "^0.8.4",
"@hookform/resolvers": "^5.2.1",
"@js-joda/core": "^5.6.5",
"@monyfox/client-transactions-importer": "workspace:*",
"@monyfox/common-data": "workspace:*",
"@monyfox/common-symbol": "workspace:*",
"@monyfox/common-symbol-exchange": "workspace:*",
Expand All @@ -37,8 +38,10 @@
"@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"framer-motion": "^12.23.12",
"graph-data-structure": "^4.5.0",
"immer": "^10.1.3",
"lucide-react": "^0.542.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
Expand All @@ -48,6 +51,7 @@
"tailwind-merge": "^3.3.1",
"tw-animate-css": "^1.3.7",
"ulid": "^3.0.1",
"use-immer": "^0.11.0",
"zod": "^4.1.5"
},
"devDependencies": {
Expand Down Expand Up @@ -78,6 +82,7 @@
"jsdom": "^26.1.0",
"msw": "^2.7.4",
"postcss": "^8.5.6",
"prettier": "^3.6.2",
"tailwindcss": "^4.1.12",
"typescript": "^5.9.2",
"vite": "^7.1.11",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,8 @@ function CreateProfileModal({
assetSymbolExchangersMetadata: { alphavantage: null },
transactions: [],
transactionCategories: [],
transactionsImporters: [],
importedTransactions: [],
lastUpdated: new Date().toISOString(),
},
},
Expand Down
12 changes: 4 additions & 8 deletions apps/client/dashboard/src/components/charts/charts-page.test.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
import { TestContextProvider } from "@/utils/tests/contexts";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { ChartsPage } from "./charts-page";
import {
fireEvent,
render,
} from "@testing-library/react";
import { fireEvent, render } from "@testing-library/react";

const originalLanguageDescriptor = Object.getOwnPropertyDescriptor(
navigator,
Expand Down Expand Up @@ -49,10 +46,9 @@ describe("ChartsPage", () => {
expect(r.getByTestId("flow-chart")).toBeInTheDocument();
expect(r.queryByTestId("net-worth-chart")).not.toBeInTheDocument();

fireEvent.click(r.getByText("Net worth"));
fireEvent.mouseDown(r.getByText("Net worth"));

// TODO: fix this test
// expect(r.queryByTestId("flow-chart")).not.toBeInTheDocument();
// expect(r.getByTestId("net-worth-chart")).toBeInTheDocument();
expect(r.queryByTestId("flow-chart")).not.toBeInTheDocument();
expect(r.getByTestId("net-worth-chart")).toBeInTheDocument();
});
});
30 changes: 27 additions & 3 deletions apps/client/dashboard/src/components/dashboard-page.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { AccountsBalance } from "@/components/accounts-balance";
import { TransactionsTable } from "@/components/transaction/transactions-table";
import { AddTransactionFloatingButton } from "@/components/transaction/transaction-form";
import { AddTransactionButton } from "@/components/transaction/transaction-form";
import { useAssetSymbolExchangeRate } from "@/hooks/use-asset-symbol-exchange-rate";
import { Spinner } from "@/components/ui/spinner";
import { DestructiveAlert } from "@/components/ui/alert";
import { ChartExpenseByCategory } from "@/components/charts/chart-expense-by-category";
import { Button } from "./ui/button";
import { ImportIcon } from "lucide-react";
import { Link } from "@tanstack/react-router";
import { useProfile } from "@/hooks/use-profile";

export function DashboardPage() {
const {
user: { id: profileId },
} = useProfile();
const { isLoading, error } = useAssetSymbolExchangeRate();

return (
Expand Down Expand Up @@ -41,14 +48,31 @@ export function DashboardPage() {
<div className="w-full px-2">
<Card>
<CardHeader>
<CardTitle>Transactions</CardTitle>
<CardTitle className="flex justify-between items-center">
<div>Transactions</div>
<div className="flex justify-end gap-2">
<Link
to="/p/$profileId/transactions/import"
params={{ profileId }}
>
<Button
variant={"secondary"}
size={"icon"}
title="Import transactions"
>
<ImportIcon />
</Button>
</Link>
<AddTransactionButton type="icon" />
</div>
</CardTitle>
</CardHeader>
<CardContent>
<TransactionsTable />
</CardContent>
</Card>
</div>
<AddTransactionFloatingButton />
<AddTransactionButton isFloating type="icon" />
</div>
);
}
209 changes: 209 additions & 0 deletions apps/client/dashboard/src/components/data-table.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useState } from "react";
import {
ColumnDef,
ColumnFiltersState,
flexRender,
getCoreRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
SortingState,
Table as ReactTable,
useReactTable,
VisibilityState,
} from "@tanstack/react-table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
ChevronLeftIcon,
ChevronRightIcon,
ChevronsLeftIcon,
ChevronsRightIcon,
} from "lucide-react";
import { Label } from "./ui/label";
import { Button } from "./ui/button";
import { TableOptions } from "@tanstack/table-core";

export function DataTable<DataT>({
data,
columns,
getRowId,
options = {},
}: {
data: Array<DataT>;
columns: ColumnDef<DataT>[];
getRowId: (row: DataT) => string;
options?: Omit<TableOptions<DataT>, "data" | "columns" | "getCoreRowModel">;
}) {
const [rowSelection, setRowSelection] = useState({});
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [sorting, setSorting] = useState<SortingState>([]);
const [pagination, setPagination] = useState({
pageIndex: 0,
pageSize: 10,
});

const table = useReactTable({
data,
columns,
state: {
sorting,
columnVisibility,
rowSelection,
columnFilters,
pagination,
},
getRowId,
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
...options,
});

return (
<div className="relative flex flex-col gap-4 overflow-auto">
<div className="overflow-hidden rounded-lg border">
<Table>
<TableHeader className="sticky top-0 z-10 bg-muted">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id} colSpan={header.colSpan}>
{flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody className="**:data-[slot=table-cell]:first:w-8">
{table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className={cell.column.id === "amount" ? "text-right" : ""}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
<PaginationContainer table={table} />
</div>
);
}

function PaginationContainer<TData>({ table }: { table: ReactTable<TData> }) {
return (
<div className="flex items-center justify-between px-4">
<div className="hidden flex-1 text-sm text-muted-foreground lg:flex">
{table.getFilteredRowModel().rows.length} rows total.
</div>
<div className="flex w-full items-center gap-8 lg:w-fit">
<div className="hidden items-center gap-2 lg:flex">
<Label htmlFor="rows-per-page" className="text-sm font-medium">
Rows per page
</Label>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value));
}}
>
<SelectTrigger className="w-20" id="rows-per-page">
<SelectValue placeholder={table.getState().pagination.pageSize} />
</SelectTrigger>
<SelectContent side="top">
{[10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex w-fit items-center justify-center text-sm font-medium">
Page {table.getState().pagination.pageIndex + 1} of{" "}
{table.getPageCount()}
</div>
<div className="ml-auto flex items-center gap-2 lg:ml-0">
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to first page</span>
<ChevronsLeftIcon />
</Button>
<Button
variant="outline"
className="size-8"
size="icon"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to previous page</span>
<ChevronLeftIcon />
</Button>
<Button
variant="outline"
className="size-8"
size="icon"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to next page</span>
<ChevronRightIcon />
</Button>
<Button
variant="outline"
className="hidden size-8 lg:flex"
size="icon"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to last page</span>
<ChevronsRightIcon />
</Button>
</div>
</div>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ describe("SettingsBackupPage", () => {
// @ts-expect-error - [0].text() exists - source: trust me bro
(await createObjectURLMock.mock.lastCall[0].text()) as string;
expect(generatedBlobText).toMatchInlineSnapshot(
`"{"id":"TEST_PROFILE_ID","user":"TEST_USER","data":{"encrypted":false,"data":{"accounts":[{"id":"ACCOUNT_1","name":"Account 1","isPersonalAsset":true},{"id":"ACCOUNT_2","name":"Account 2","isPersonalAsset":true}],"assetSymbols":[{"id":"EUR","code":"EUR","displayName":"EUR","type":"fiat"},{"id":"USD","code":"USD","displayName":"USD","type":"fiat"},{"id":"CHF","code":"CHF","displayName":"CHF","type":"fiat"},{"id":"MWRD","code":"MWRD","displayName":"MWRD ETF name","type":"stock"}],"assetSymbolExchanges":[],"assetSymbolExchangersMetadata":{"alphavantage":{"apiKey":"TEST_API_KEY"}},"transactions":[{"id":"TRANSACTION_1","description":"Income","transactionDate":"2024-01-01","accountingDate":"2024-01-01","transactionCategoryId":"CATEGORY_1","from":{"account":{"name":"Income"},"amount":950,"symbolId":"EUR"},"to":{"account":{"id":"ACCOUNT_1"},"amount":950,"symbolId":"EUR"}},{"id":"TRANSACTION_2","description":"Expense","transactionDate":"2024-01-01","accountingDate":"2024-01-01","transactionCategoryId":null,"from":{"account":{"id":"ACCOUNT_1"},"amount":23,"symbolId":"EUR"},"to":{"account":{"name":"Expense"},"amount":23,"symbolId":"EUR"}},{"id":"TRANSACTION_3","description":"Income USD","transactionDate":"2024-01-01","accountingDate":"2024-01-01","transactionCategoryId":"CATEGORY_1","from":{"account":{"name":"Income"},"amount":950,"symbolId":"USD"},"to":{"account":{"id":"ACCOUNT_1"},"amount":950,"symbolId":"USD"}}],"transactionCategories":[{"id":"CATEGORY_1","name":"Category 1","parentTransactionCategoryId":null},{"id":"CATEGORY_1_1","name":"Subcategory 1-1","parentTransactionCategoryId":"CATEGORY_1"}],"lastUpdated":"2024-01-01T00:00:00.000Z"}},"schemaVersion":"1"}"`,
`"{"id":"TEST_PROFILE_ID","user":"TEST_USER","data":{"encrypted":false,"data":{"accounts":[{"id":"ACCOUNT_1","name":"Account 1","isPersonalAsset":true},{"id":"ACCOUNT_2","name":"Account 2","isPersonalAsset":true}],"assetSymbols":[{"id":"EUR","code":"EUR","displayName":"EUR","type":"fiat"},{"id":"USD","code":"USD","displayName":"USD","type":"fiat"},{"id":"CHF","code":"CHF","displayName":"CHF","type":"fiat"},{"id":"MWRD","code":"MWRD","displayName":"MWRD ETF name","type":"stock"}],"assetSymbolExchanges":[],"assetSymbolExchangersMetadata":{"alphavantage":{"apiKey":"TEST_API_KEY"}},"transactions":[{"id":"TRANSACTION_1","description":"Income","transactionDate":"2024-01-01","accountingDate":"2024-01-01","transactionCategoryId":"CATEGORY_1","from":{"account":{"name":"Income"},"amount":950,"symbolId":"EUR"},"to":{"account":{"id":"ACCOUNT_1"},"amount":950,"symbolId":"EUR"}},{"id":"TRANSACTION_2","description":"Expense","transactionDate":"2024-01-01","accountingDate":"2024-01-01","transactionCategoryId":null,"from":{"account":{"id":"ACCOUNT_1"},"amount":23,"symbolId":"EUR"},"to":{"account":{"name":"Expense"},"amount":23,"symbolId":"EUR"}},{"id":"TRANSACTION_3","description":"Income USD","transactionDate":"2024-01-01","accountingDate":"2024-01-01","transactionCategoryId":"CATEGORY_1","from":{"account":{"name":"Income"},"amount":950,"symbolId":"USD"},"to":{"account":{"id":"ACCOUNT_1"},"amount":950,"symbolId":"USD"}}],"transactionCategories":[{"id":"CATEGORY_1","name":"Category 1","parentTransactionCategoryId":null},{"id":"CATEGORY_1_1","name":"Subcategory 1-1","parentTransactionCategoryId":"CATEGORY_1"}],"transactionsImporters":[{"id":"IMPORTER_1","name":"Importer 1","data":{"provider":"chase-card","defaultAccountId":"ACCOUNT_1","defaultSymbolId":"USD"}}],"importedTransactions":[],"lastUpdated":"2024-01-01T00:00:00.000Z"}},"schemaVersion":"1"}"`,
);
});
});
Loading
Loading