diff --git a/cli/.idea/runConfigurations/load_csv.xml b/cli/.idea/runConfigurations/extract.xml similarity index 70% rename from cli/.idea/runConfigurations/load_csv.xml rename to cli/.idea/runConfigurations/extract.xml index a136210..c4fe267 100644 --- a/cli/.idea/runConfigurations/load_csv.xml +++ b/cli/.idea/runConfigurations/extract.xml @@ -1,8 +1,8 @@ - + - + diff --git a/cli/.idea/runConfigurations/load_sqlite.xml b/cli/.idea/runConfigurations/load.xml similarity index 70% rename from cli/.idea/runConfigurations/load_sqlite.xml rename to cli/.idea/runConfigurations/load.xml index 1b7d0f5..871b603 100644 --- a/cli/.idea/runConfigurations/load_sqlite.xml +++ b/cli/.idea/runConfigurations/load.xml @@ -1,8 +1,8 @@ - + - + diff --git a/cli/.idea/runConfigurations/load_plaid_data.xml b/cli/.idea/runConfigurations/transform.xml similarity index 68% rename from cli/.idea/runConfigurations/load_plaid_data.xml rename to cli/.idea/runConfigurations/transform.xml index 0bb7d91..fbc2a2d 100644 --- a/cli/.idea/runConfigurations/load_plaid_data.xml +++ b/cli/.idea/runConfigurations/transform.xml @@ -1,8 +1,8 @@ - + - + diff --git a/cli/internal/cmd/load.go b/cli/internal/cmd/load.go index 6f643b6..2fb6f27 100644 --- a/cli/internal/cmd/load.go +++ b/cli/internal/cmd/load.go @@ -37,6 +37,11 @@ func LoadCmd(ctx context.Context) *cobra.Command { return err } + err = domain.LoadAccountBalances(ctx, queries, importLogId) + if err != nil { + return err + } + return nil }) if err != nil { diff --git a/cli/internal/db/models.go b/cli/internal/db/models.go index 7b161fa..8dd15cc 100644 --- a/cli/internal/db/models.go +++ b/cli/internal/db/models.go @@ -26,12 +26,13 @@ type Account struct { } type AccountBalance struct { - ID int64 Current float64 Available float64 IsoCurrencyCode string AccountPlaidId string - CreatedAt time.Time + Date string + ImportedAt time.Time + ImportLogId sql.NullInt64 } type Budget struct { @@ -41,7 +42,7 @@ type Budget struct { Range int64 } -type BudgetRule struct { +type BudgetFilter struct { ID int64 BudgetId int64 Column string diff --git a/cli/internal/db/query.sql b/cli/internal/db/query.sql index ee06d05..9ea1fd2 100644 --- a/cli/internal/db/query.sql +++ b/cli/internal/db/query.sql @@ -119,5 +119,12 @@ INSERT INTO "AccountBalance"("current", "available", "isoCurrencyCode", "accountPlaidId", - "createdAt") -VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP); \ No newline at end of file + "date", + "importLogId", + "importedAt") +VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) +ON CONFLICT DO UPDATE SET "current"=excluded."current", + "available"=excluded."available", + "isoCurrencyCode"=excluded."isoCurrencyCode", + "accountPlaidId"=excluded."accountPlaidId", + "date"=excluded."date"; \ No newline at end of file diff --git a/cli/internal/db/query.sql.go b/cli/internal/db/query.sql.go index 5797566..ef986ad 100644 --- a/cli/internal/db/query.sql.go +++ b/cli/internal/db/query.sql.go @@ -16,8 +16,15 @@ INSERT INTO "AccountBalance"("current", "available", "isoCurrencyCode", "accountPlaidId", - "createdAt") -VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) + "date", + "importLogId", + "importedAt") +VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) +ON CONFLICT DO UPDATE SET "current"=excluded."current", + "available"=excluded."available", + "isoCurrencyCode"=excluded."isoCurrencyCode", + "accountPlaidId"=excluded."accountPlaidId", + "date"=excluded."date" ` type AccountBalanceCreateParams struct { @@ -25,6 +32,8 @@ type AccountBalanceCreateParams struct { Available float64 IsoCurrencyCode string AccountPlaidId string + Date string + ImportLogId sql.NullInt64 } func (q *Queries) AccountBalanceCreate(ctx context.Context, arg AccountBalanceCreateParams) error { @@ -33,6 +42,8 @@ func (q *Queries) AccountBalanceCreate(ctx context.Context, arg AccountBalanceCr arg.Available, arg.IsoCurrencyCode, arg.AccountPlaidId, + arg.Date, + arg.ImportLogId, ) return err } diff --git a/cli/internal/db/schema.sql b/cli/internal/db/schema.sql index 12b0803..836ea03 100644 --- a/cli/internal/db/schema.sql +++ b/cli/internal/db/schema.sql @@ -65,26 +65,30 @@ CREATE TABLE IF NOT EXISTS "Account" ( CONSTRAINT "Account_institutionPlaidId_fkey" FOREIGN KEY ("institutionPlaidId") REFERENCES "Institution" ("plaidId") ON DELETE SET NULL ON UPDATE CASCADE, CONSTRAINT "Account_importLogId_fkey" FOREIGN KEY ("importLogId") REFERENCES "ImportLog" ("id") ON DELETE SET NULL ON UPDATE CASCADE ); -CREATE TABLE IF NOT EXISTS "AccountBalance" ( - "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - "current" REAL NOT NULL, - "available" REAL NOT NULL, - "isoCurrencyCode" TEXT NOT NULL, - "accountPlaidId" TEXT NOT NULL, - "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT "AccountBalance_accountPlaidId_fkey" FOREIGN KEY ("accountPlaidId") REFERENCES "Account" ("plaidId") ON DELETE RESTRICT ON UPDATE CASCADE -); CREATE TABLE IF NOT EXISTS "Budget" ( "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, "name" TEXT NOT NULL, "amount" REAL NOT NULL, "range" INTEGER NOT NULL ); -CREATE TABLE IF NOT EXISTS "BudgetRule" ( +CREATE TABLE IF NOT EXISTS "BudgetFilter" ( "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, "budgetId" INTEGER NOT NULL, "column" TEXT NOT NULL, "operator" TEXT NOT NULL DEFAULT 'CONTAINS', "value" TEXT NOT NULL, - CONSTRAINT "BudgetRule_budgetId_fkey" FOREIGN KEY ("budgetId") REFERENCES "Budget" ("id") ON DELETE RESTRICT ON UPDATE CASCADE + CONSTRAINT "BudgetFilter_budgetId_fkey" FOREIGN KEY ("budgetId") REFERENCES "Budget" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); +CREATE TABLE IF NOT EXISTS "AccountBalance" ( + "current" REAL NOT NULL, + "available" REAL NOT NULL, + "isoCurrencyCode" TEXT NOT NULL, + "accountPlaidId" TEXT NOT NULL, + "date" TEXT NOT NULL, + "importedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "importLogId" INTEGER, + + PRIMARY KEY ("accountPlaidId", "date"), + CONSTRAINT "AccountBalance_accountPlaidId_fkey" FOREIGN KEY ("accountPlaidId") REFERENCES "Account" ("plaidId") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "AccountBalance_importLogId_fkey" FOREIGN KEY ("importLogId") REFERENCES "ImportLog" ("id") ON DELETE SET NULL ON UPDATE CASCADE ); diff --git a/cli/internal/domain/csv.go b/cli/internal/domain/csv.go index b6e8022..72e4d05 100644 --- a/cli/internal/domain/csv.go +++ b/cli/internal/domain/csv.go @@ -81,3 +81,10 @@ type Account struct { Type plaid.AccountType `csv:"type"` PlaidItemID string `csv:"plaidItemId"` } + +type AccountBalance struct { + PlaidAccountID string `csv:"plaidAccountId"` + Available float64 `csv:"available"` + Current float64 `csv:"current"` + Date string `csv:"date"` +} diff --git a/cli/internal/domain/load.go b/cli/internal/domain/load.go index 042883e..34953a3 100644 --- a/cli/internal/domain/load.go +++ b/cli/internal/domain/load.go @@ -175,18 +175,36 @@ func LoadAccounts(ctx context.Context, queries *db.Queries, importLogId int64) e } log.Println("created account", account.Name) + } + + return nil +} + +func LoadAccountBalances(ctx context.Context, queries *db.Queries, importLogId int64) error { + csvFile := path.Join(os.Getenv("HOME"), ".config", "budgeted", "csv", "account_balances.csv") + data, err := os.Open(csvFile) + if err != nil { + return errors.Wrapf(err, "failed to read file: %s", csvFile) + } + + var accountBalances []AccountBalance + if err := gocsv.Unmarshal(data, &accountBalances); err != nil { + return errors.Wrapf(err, "failed to parse CSV file: %s", csvFile) + } + for _, accountBalance := range accountBalances { err = queries.AccountBalanceCreate(ctx, db.AccountBalanceCreateParams{ - Current: account.CurrentBalance, - Available: account.AvailableBalance, - IsoCurrencyCode: account.ISOCurrencyCode, - AccountPlaidId: account.PlaidID, + AccountPlaidId: accountBalance.PlaidAccountID, + Current: accountBalance.Current, + Available: accountBalance.Available, + Date: accountBalance.Date, + ImportLogId: sql.NullInt64{Int64: importLogId, Valid: true}, }) if err != nil { return err } - log.Println("created account balance", account.Name) + log.Println("created account balance", accountBalance.Current) } return nil diff --git a/cli/internal/domain/transform.go b/cli/internal/domain/transform.go index 987504a..41bfa72 100644 --- a/cli/internal/domain/transform.go +++ b/cli/internal/domain/transform.go @@ -3,14 +3,11 @@ package domain import ( "context" "encoding/json" - "errors" "fmt" - "io/fs" "log" "os" "path/filepath" "strings" - "time" "github.com/gocarina/gocsv" "github.com/plaid/plaid-go/v20/plaid" @@ -134,72 +131,88 @@ func TransformTransactions(ctx context.Context, jsonStorage string, csvStorage s // The unfortunate side-effect of that is rebuilding a SQLite database will no longer rebuild // historical account balances. func TransformAccounts(ctx context.Context, jsonStorage string, csvStorage string) error { - var accountsCSV []Account - accountsPath := fmt.Sprintf("%s/accounts", jsonStorage) + accountMap := make(map[string]Account) + accountBalanceMap := make(map[string]AccountBalance) + var accounts []Account + var accountBalances []AccountBalance - files, err := os.ReadDir(accountsPath) - if err != nil { - log.Fatal(err) - } - - var newestTime time.Time - var newestFile fs.DirEntry - for _, file := range files { - if file.IsDir() { - continue + err := filepath.WalkDir(accountsPath, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err } + if d.IsDir() { + return nil + } + + log.Println("path", path) + date := d.Name()[:10] - info, err := file.Info() + bytes, err := os.ReadFile(path) if err != nil { log.Fatal(err) } - if newestFile == nil || info.ModTime().After(newestTime) { - newestFile = file - newestTime = info.ModTime() + response := plaid.AccountsGetResponse{} + if err = json.Unmarshal(bytes, &response); err != nil { + return err } + + for _, account := range response.GetAccounts() { + balance := account.GetBalances() + item := response.GetItem() + + accountMap[account.GetAccountId()] = Account{ + PlaidID: account.GetAccountId(), + AvailableBalance: balance.GetAvailable(), + CurrentBalance: balance.GetCurrent(), + ISOCurrencyCode: balance.GetIsoCurrencyCode(), + Limit: balance.GetLimit(), + Mask: account.GetMask(), + Name: account.GetName(), + OfficialName: account.GetOfficialName(), + Subtype: account.GetSubtype(), + Type: account.GetType(), + PlaidItemID: item.GetItemId(), + } + + balanceKey := fmt.Sprintf("%s-%s", date, account.GetAccountId()) + + accountBalanceMap[balanceKey] = AccountBalance{ + PlaidAccountID: account.GetAccountId(), + Date: date, + Available: balance.GetAvailable(), + Current: balance.GetCurrent(), + } + } + + return nil + }) + + for _, account := range accountMap { + accounts = append(accounts, account) } - if newestFile == nil { - return errors.New("no valid account files found") + for _, accountBalance := range accountBalanceMap { + accountBalances = append(accountBalances, accountBalance) } - jsonFilePath := accountsPath + "/" + newestFile.Name() - bytes, err := os.ReadFile(jsonFilePath) - if err != nil { - log.Fatal(err) - } + var csvContent string + var fp string - response := plaid.AccountsGetResponse{} - if err = json.Unmarshal(bytes, &response); err != nil { + csvContent, err = gocsv.MarshalString(&accounts) + if err != nil { return err } - - for _, account := range response.GetAccounts() { - balance := account.GetBalances() - item := response.GetItem() - - accountsCSV = append(accountsCSV, Account{ - PlaidID: account.GetAccountId(), - AvailableBalance: balance.GetAvailable(), - CurrentBalance: balance.GetCurrent(), - ISOCurrencyCode: balance.GetIsoCurrencyCode(), - Limit: balance.GetLimit(), - Mask: account.GetMask(), - Name: account.GetName(), - OfficialName: account.GetOfficialName(), - Subtype: account.GetSubtype(), - Type: account.GetType(), - PlaidItemID: item.GetItemId(), - }) + fp = filepath.Join(csvStorage, "accounts.csv") + if err := os.WriteFile(fp, []byte(csvContent), 0644); err != nil { + return err } - csvContent, err := gocsv.MarshalString(&accountsCSV) + csvContent, err = gocsv.MarshalString(&accountBalances) if err != nil { return err } - - fp := filepath.Join(csvStorage, "accounts.csv") + fp = filepath.Join(csvStorage, "account_balances.csv") if err := os.WriteFile(fp, []byte(csvContent), 0644); err != nil { return err } diff --git a/electron/main/api.ts b/electron/main/api.ts index 31193fc..a1b4000 100644 --- a/electron/main/api.ts +++ b/electron/main/api.ts @@ -248,7 +248,7 @@ export const router = t.router({ }), accountBalances: loggedProcedure.query(async () => { return await prisma.accountBalance.findMany({ - orderBy: { createdAt: 'asc' }, + orderBy: { date: 'asc' }, include: { account: true }, }) }), diff --git a/prisma/migrations/20240207161829_add_log_to_balances/migration.sql b/prisma/migrations/20240207161829_add_log_to_balances/migration.sql new file mode 100644 index 0000000..0132d1a --- /dev/null +++ b/prisma/migrations/20240207161829_add_log_to_balances/migration.sql @@ -0,0 +1,19 @@ +-- RedefineTables +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_AccountBalance" ( + "current" REAL NOT NULL, + "available" REAL NOT NULL, + "isoCurrencyCode" TEXT NOT NULL, + "accountPlaidId" TEXT NOT NULL, + "date" TEXT NOT NULL, + "importLogId" INTEGER, + + PRIMARY KEY ("accountPlaidId", "date"), + CONSTRAINT "AccountBalance_accountPlaidId_fkey" FOREIGN KEY ("accountPlaidId") REFERENCES "Account" ("plaidId") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "AccountBalance_importLogId_fkey" FOREIGN KEY ("importLogId") REFERENCES "ImportLog" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); +INSERT INTO "new_AccountBalance" ("accountPlaidId", "available", "current", "date", "isoCurrencyCode") SELECT "accountPlaidId", "available", "current", "date", "isoCurrencyCode" FROM "AccountBalance"; +DROP TABLE "AccountBalance"; +ALTER TABLE "new_AccountBalance" RENAME TO "AccountBalance"; +PRAGMA foreign_key_check; +PRAGMA foreign_keys=ON; diff --git a/prisma/migrations/20240207161950_import_timestamp/migration.sql b/prisma/migrations/20240207161950_import_timestamp/migration.sql new file mode 100644 index 0000000..f123c01 --- /dev/null +++ b/prisma/migrations/20240207161950_import_timestamp/migration.sql @@ -0,0 +1,20 @@ +-- RedefineTables +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_AccountBalance" ( + "current" REAL NOT NULL, + "available" REAL NOT NULL, + "isoCurrencyCode" TEXT NOT NULL, + "accountPlaidId" TEXT NOT NULL, + "date" TEXT NOT NULL, + "importedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "importLogId" INTEGER, + + PRIMARY KEY ("accountPlaidId", "date"), + CONSTRAINT "AccountBalance_accountPlaidId_fkey" FOREIGN KEY ("accountPlaidId") REFERENCES "Account" ("plaidId") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "AccountBalance_importLogId_fkey" FOREIGN KEY ("importLogId") REFERENCES "ImportLog" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); +INSERT INTO "new_AccountBalance" ("accountPlaidId", "available", "current", "date", "importLogId", "isoCurrencyCode") SELECT "accountPlaidId", "available", "current", "date", "importLogId", "isoCurrencyCode" FROM "AccountBalance"; +DROP TABLE "AccountBalance"; +ALTER TABLE "new_AccountBalance" RENAME TO "AccountBalance"; +PRAGMA foreign_key_check; +PRAGMA foreign_keys=ON; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9dc9f77..a44579a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -72,21 +72,25 @@ model Account { } model ImportLog { - id Int @id @default(autoincrement()) - syncStartedAt DateTime @default(now()) - syncCompletedAt DateTime? @updatedAt + id Int @id @default(autoincrement()) + syncStartedAt DateTime @default(now()) + syncCompletedAt DateTime? @updatedAt transactionsCount Int? transactions Transaction[] accounts Account[] + accountBalances AccountBalance[] } model AccountBalance { current Float available Float isoCurrencyCode String - account Account @relation(fields: [accountPlaidId], references: [plaidId]) + account Account @relation(fields: [accountPlaidId], references: [plaidId]) accountPlaidId String date String + importedAt DateTime @default(now()) + ImportLog ImportLog? @relation(fields: [importLogId], references: [id]) + importLogId Int? @@id([accountPlaidId, date]) } diff --git a/src/components/graph/Axis.tsx b/src/components/graph/Axis.tsx index 0ca79e0..eff9198 100644 --- a/src/components/graph/Axis.tsx +++ b/src/components/graph/Axis.tsx @@ -7,10 +7,12 @@ type Props = { y: d3.ScaleLinear pixelsPerTick?: number placement?: 'top' | 'bottom' | 'left' | 'right' - setHoveredTick?: React.Dispatch> + setHoveredTick?: React.Dispatch< + React.SetStateAction + > hoveredTick?: string pad?: number - format?: (value: number | Date) => string + format?: ((value: number | Date) => string) | string maxTicks?: number } @@ -28,6 +30,7 @@ export function Axis({ const main = axis === 'x' ? x : y const r = main.range() as [number, number] const range = [r[0], r[1] + (pad ?? 0) * 2] as const + const scale = axis === 'x' ? x : y const rangeX = x.range() as [number, number] const rangeY = y.range() as [number, number] @@ -39,8 +42,16 @@ export function Axis({ maxTicks, ) + const formatter = + typeof format === 'string' + ? scale.tickFormat(numberOfTicksTarget, format) + : typeof format === 'function' + ? format + : scale.tickFormat(numberOfTicksTarget) + return main.ticks(numberOfTicksTarget).map((value) => ({ - value: format(value), + label: formatter(value as Date /* 😔 */), + value: value, x: axis === 'x' ? x(value) + (pad ?? 0) : 0, y: axis === 'y' ? y(value) + (pad ?? 0) : 0, })) @@ -63,9 +74,9 @@ export function Axis({ - {ticks.map(({ value, x, y }) => ( + {ticks.map(({ label, value, x, y }) => ( setHoveredTick(value))} > @@ -81,7 +92,7 @@ export function Axis({ }} fill="currentColor" > - {value.toLocaleString()} + {label} ))} diff --git a/src/components/graph/NetWorthChart.tsx b/src/components/graph/NetWorthChart.tsx index b6fa20f..a5cae37 100644 --- a/src/components/graph/NetWorthChart.tsx +++ b/src/components/graph/NetWorthChart.tsx @@ -2,7 +2,7 @@ import { useDimensions } from '@/lib/useDimensions' import * as d3 from 'd3' import { Dispatch, Fragment, SetStateAction, useMemo, useState } from 'react' import { Axis } from './Axis' -import { format } from 'date-fns' +import { format, set } from 'date-fns' import { formatMoneyK } from '@/lib/money' const DEFAULT_WIDTH = 640 @@ -11,21 +11,20 @@ const DEFAULT_HEIGHT = 400 interface Datum { date: string amount: number + // name: string data: { - date: string amount: number name: string }[] } interface Props { - filteredData: Datum[] - date?: string - setDate: Dispatch> + data: Datum[] + setAccount?: React.Dispatch> + account?: string } -export function NetWorthChart({ filteredData, date, setDate }: Props) { - const [hoveredTick, setHoveredTick] = useState() +export function NetWorthChart({ data, account, setAccount }: Props) { const [ref, dimensions] = useDimensions({ liveMeasure: true }) const MARGIN = 20 @@ -37,123 +36,96 @@ export function NetWorthChart({ filteredData, date, setDate }: Props) { const width = (dimensions.width ?? DEFAULT_WIDTH) - borderFix const height = (dimensions.height ?? DEFAULT_HEIGHT) - borderFix + const series = useMemo( + () => + d3 + .stack() + .keys(d3.union(...data.map((d) => d.data.map((d) => d.name)))) + .value( + (datum, key) => datum.data.find((d) => d.name === key)?.amount ?? 0, + )(data), + [data], + ) + const x = useMemo(() => { - if (!filteredData) return - const ex = d3.extent(filteredData, (d) => new Date(d.date)) + if (!data) return + const ex = d3.extent(data, (d) => new Date(d.date)) if (!ex[0]) return return d3 .scaleUtc() .domain(ex) .range([marginLeft, width - marginRight]) - }, [filteredData, marginLeft, width, marginRight]) + }, [data, marginLeft, width, marginRight]) const y = useMemo(() => { - if (!filteredData) return - const ex = d3.extent(filteredData, (d) => d.amount) + if (!data) return + const ex = d3.extent(data, (d) => d.amount) return d3 .scaleLinear() .domain([0, ex[1] ?? 0]) .range([height - marginBottom, marginTop]) - }, [filteredData, marginTop, height, marginBottom]) + }, [data, marginTop, height, marginBottom]) + + const maxTicks = useMemo(() => { + const first = data[0] + const last = data[data.length - 1] + if (!first || !last) return 30 + + const firstDate = new Date(first.date) + const lastDate = new Date(last.date) + const days = + Math.abs( + set(lastDate, { + hours: 0, + minutes: 0, + seconds: 0, + milliseconds: 0, + }).getTime() - + set(firstDate, { + hours: 0, + minutes: 0, + seconds: 0, + milliseconds: 0, + }).getTime(), + ) / + (1000 * 60 * 60 * 24) + return Math.min(30, days) + }, [data]) if (!x || !y) return
loading
- const line = d3.line<{ date: string; amount: number }>( - (d) => (d.date === '' ? marginLeft - 15 : x(new Date(d.date))), - (d) => (d.date === '' ? height - marginBottom : y(d.amount)), - ) + const area = d3 + .area>() + .x((d) => x(new Date(d.data.date))) + .y0((d) => y(d[0])) + .y1((d) => y(d[1])) return (
- {'width' in dimensions && filteredData && ( + {'width' in dimensions && data && ( format(date as Date, 'MMM d')} - maxTicks={filteredData.length} + maxTicks={maxTicks} /> - - - {filteredData.map((d) => ( - - setHoveredTick(d.date)} - onMouseLeave={() => setHoveredTick(undefined)} - onClick={() => setDate(d.date)} - opacity={0.1} - /> - - {d.data.map((d) => ( - - - {d.name} - - - {formatMoneyK(d.amount)} - - - ))} - + {series.map((s) => ( + setAccount?.(s.key)} + onMouseLeave={() => setAccount?.(undefined)} + d={area(s) ?? undefined} + /> ))} )} diff --git a/src/components/pages/NetWorthPage.tsx b/src/components/pages/NetWorthPage.tsx index ebcd3bf..021eb45 100644 --- a/src/components/pages/NetWorthPage.tsx +++ b/src/components/pages/NetWorthPage.tsx @@ -1,22 +1,17 @@ import { trpc } from '@/lib/trpc' -import { Fragment, useMemo, useState } from 'react' -import { sub, add, format } from 'date-fns' -import { Dialog, Transition } from '@headlessui/react' -import { Button } from '../ui/button' +import { useMemo, useState } from 'react' +import { sub, format } from 'date-fns' import { InlineInput } from '../ui/input' import { useLocalStorage } from '@/lib/useLocalStorage' import { z } from 'zod' -import { BudgetChart } from '../graph/BudgetChart' -import { formatMoney } from '@/lib/money' -import { parseMoney } from '@/lib/money' -import { useSearchParams } from 'react-router-dom' import { NetWorthChart } from '../graph/NetWorthChart' +import { formatMoneyK } from '@/lib/money' +import { cn } from '@/lib/utils' const DEFAULT_DAY_RANGE = 30 export function NetWorthPage() { - const [date, setDate] = useState() - const [closeModal, setCloseModal] = useState(false) + const [account, setAccount] = useState() const { data } = trpc.accountBalances.useQuery() const [dayRange, setDayRange] = useLocalStorage( @@ -25,12 +20,6 @@ export function NetWorthPage() { DEFAULT_DAY_RANGE, ) - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const minDate = useMemo( - () => format(sub(new Date(), { days: dayRange }), 'yyyy-MM-dd'), - [dayRange], - ) - const filteredData = useMemo(() => { if (!data) return null @@ -43,7 +32,7 @@ export function NetWorthPage() { const map: Record = {} for (const datum of data.map((d) => ({ - date: format(new Date(d.createdAt), 'yyyy-MM-dd'), + date: format(new Date(d.date), 'yyyy-MM-dd'), amount: d.current, name: d.account.name, }))) { @@ -60,9 +49,21 @@ export function NetWorthPage() { frames.push({ date, amount, data: map[date] ?? [] }) } - return frames + return frames.sort( + (a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(), + ) }, [data]) + const accounts = useMemo(() => { + if (!filteredData) return [] + const last = filteredData[filteredData.length - 1] + if (!last) return [] + + return last.data + }, [filteredData]) + + const sum = accounts.reduce((a, b) => a + b.amount, 0) + return ( <>
@@ -85,14 +86,38 @@ export function NetWorthPage() { days.
-
- {filteredData && ( - - )} +
+
+
+ {formatMoneyK(filteredData?.[filteredData.length - 1]?.amount ?? 0)} +
+
+
+ {filteredData && ( + + )} +
+
+ {accounts.map((ac) => ( +
setAccount(ac.name)} + onMouseLeave={() => setAccount(undefined)} + > + {ac.name} + {formatMoneyK(ac.amount)} +
+ ))} +
)