diff --git a/CLAUDE.md b/CLAUDE.md index ab518e64..689c60e5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -190,3 +190,13 @@ Enforcement layers, fast → slow: `test:changed`/watch (you, locally) → pre-c 5. Uncategorized — flagged for manual review Each tier sets `categorySource` on the transaction (`"rule"` | `"merchant_default"` | `"pfc"` | `"ai"` | `"manual"`) to track provenance. Manual user edits always set `"manual"` and are never overwritten by lower tiers. + + + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + diff --git a/README.md b/README.md index ebf71944..893cdac3 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,16 @@ Claude: Based on your transactions, you spent $342.18 on dining out in April... - **CSV/OFX/QFX import & CSV export** — for accounts not supported by Plaid, and for getting your data out - **Self-hosted** — Docker Compose with PostgreSQL, your data never leaves your server +
+
+{formatDateShort(label ?? "")}
- {payload.map((entry: TooltipEntry) => ( + {entries.map((entry: TooltipEntry) => ({entry.name}: {centsToDisplay(entry.value)}
@@ -56,14 +72,38 @@ export function NetWorthAreaChart({ data, mode = "multi", seriesName = "Value" } } if (mode === "single") { + const points = data as SinglePoint[]; + const boundary = coverageBoundary(points.map((p) => ({ ...p, netWorth: p.value }))); + + // Split the series at the boundary so the stretch that isn't yet net worth + // renders as a muted dashed line instead of a confident solid one. The + // boundary point belongs to BOTH keys, or the two segments would not meet. + // + // Three shapes to cover: no partial span (everything solid — reports, and + // any household with even history), a partial span that resolves (split at + // the boundary), and coverage that never completes (everything dashed). + const neverCompletes = boundary.index === -1; + const split: ChartDataPoint[] = points.map((p, i) => { + if (!boundary.hasPartial) return { date: p.date, partial: null, covered: p.value }; + if (neverCompletes) return { date: p.date, partial: p.value, covered: null }; + return { + date: p.date, + partial: i <= boundary.index ? p.value : null, + covered: i >= boundary.index ? p.value : null, + }; + }); + return (+ + + Dashed: {coverage.minCovered === coverage.maxPartialCovered + ? coverage.minCovered + : `${coverage.minCovered}–${coverage.maxPartialCovered}`}{" "} + of {coverage.totalAccounts} accounts had balance history + {coverage.date ? ` before ${formatDateShort(coverage.date)}` : ""}, so it is not + yet net worth. + +
+ )} ); } diff --git a/src/components/organisms/report-net-worth.tsx b/src/components/organisms/report-net-worth.tsx index ee026553..498ec519 100644 --- a/src/components/organisms/report-net-worth.tsx +++ b/src/components/organisms/report-net-worth.tsx @@ -3,10 +3,10 @@ import { Wallet, TrendingUp } from "lucide-react"; import { NetWorthAreaChart } from "@/components/atoms/net-worth-area-chart"; import { ReportSummaryBar, type SummaryItem } from "@/components/atoms/report-summary-bar"; -import type { NetWorthPoint } from "@/queries/dashboard"; +import type { NetWorthSeriesPoint } from "@/queries/dashboard"; interface ReportNetWorthProps { - data: NetWorthPoint[]; + data: NetWorthSeriesPoint[]; } export function ReportNetWorth({ data }: ReportNetWorthProps) { diff --git a/src/components/organisms/report-tabs.tsx b/src/components/organisms/report-tabs.tsx index a09bd75c..39675fe4 100644 --- a/src/components/organisms/report-tabs.tsx +++ b/src/components/organisms/report-tabs.tsx @@ -5,7 +5,7 @@ import { PieChart, ArrowLeftRight, Waypoints, TrendingUp, LineChart } from "luci import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useSearchParamFilters } from "@/hooks/use-search-param-filters"; import type { SpendingRow, IncomeExpenseRow, CategoryTrendRow, IncomeExpenseCategoryRow, SafeToSpendResult } from "@/queries/reports"; -import type { NetWorthPoint } from "@/queries/dashboard"; +import type { NetWorthSeriesPoint } from "@/queries/dashboard"; import type { SankeyNode, SankeyLink } from "@/components/organisms/sankey-chart"; // Each report panel pulls in recharts (or d3-sankey). Load only the active @@ -40,7 +40,7 @@ interface ReportTabsProps { incomeExpenseData?: IncomeExpenseRow[]; incomeExpenseCategoryData?: IncomeExpenseCategoryRow[]; trendsData?: CategoryTrendRow[]; - netWorthData?: NetWorthPoint[]; + netWorthData?: NetWorthSeriesPoint[]; sankeyNodes?: SankeyNode[]; sankeyLinks?: SankeyLink[]; cashFlowBarData?: IncomeExpenseRow[]; diff --git a/src/components/organisms/simplefin-connect-flow.tsx b/src/components/organisms/simplefin-connect-flow.tsx index af97b262..2548f8ce 100644 --- a/src/components/organisms/simplefin-connect-flow.tsx +++ b/src/components/organisms/simplefin-connect-flow.tsx @@ -13,6 +13,7 @@ import { import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; import { Label } from "@/components/ui/label"; +import { inferAccountTypeFromName } from "@/lib/account-utils"; import { Select, SelectContent, @@ -95,7 +96,11 @@ export function SimplefinConnectFlow({ variant = "dropdown-item" }: SimplefinCon accounts.push({ connectionId: connection.connectionId, ...account, - type: account.existingType ?? "checking", + // SimpleFIN sends no account type. Defaulting everything to + // "checking" filed credit cards as deposit accounts, so debt + // never registered — guess from the name and let the user + // correct it on this very screen. + type: account.existingType ?? inferAccountTypeFromName(account.name), }); } } diff --git a/src/db/schema/accounts.ts b/src/db/schema/accounts.ts index 6aac243c..d9378d2c 100644 --- a/src/db/schema/accounts.ts +++ b/src/db/schema/accounts.ts @@ -29,7 +29,20 @@ export const accounts = pgTable( officialName: text("official_name"), type: text("type", { enum: ACCOUNT_TYPES }).notNull(), subtype: text("subtype"), + // Signed cents, and the sign is load-bearing: money owed is ALWAYS + // negative, for every account type. That makes net worth the plain sum of + // this column, so a mis-typed account can never invert it. + // + // Connectors disagree, so both are normalized on the way in: + // SimpleFIN — already reports liabilities negative; passes through. + // Plaid — reports credit/loan `balances.current` POSITIVE when owed + // ("a positive balance indicates amount owed"), so it is + // flipped by plaidBalanceToCents() at every ingest site. + // + // balance_history.balance carries the same convention. currentBalance: integer("current_balance"), + // Not sign-normalized: for a credit account Plaid reports this as available + // *credit*, which is spending capacity rather than a debt. availableBalance: integer("available_balance"), creditLimit: integer("credit_limit"), currency: text("currency").default("USD"), diff --git a/src/db/seed/demo.ts b/src/db/seed/demo.ts index f869fb5c..f9880ea1 100644 --- a/src/db/seed/demo.ts +++ b/src/db/seed/demo.ts @@ -412,9 +412,12 @@ export async function seedDemoHousehold(db: LedgrDb = defaultDb): Promise