From 3f42f215040781f21a4cd817645e1f5835654c76 Mon Sep 17 00:00:00 2001 From: Munkherdene Date: Sun, 23 Aug 2026 00:04:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20money-clarity=20home=20=E2=80=94?= =?UTF-8?q?=20cash-flow=20hero=20+=20where-it-went=20+=20recurring=20+=20n?= =?UTF-8?q?et=20worth=20+=20categorize=20nudge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/hooks/reads.ts | 5 + src/api/keys.ts | 1 + src/features/home/DashboardView.tsx | 643 ++++++++++++------ src/features/home/buildHome.ts | 2 +- src/features/home/cashFlowTrend.test.ts | 35 + src/features/home/cashFlowTrend.ts | 43 ++ src/features/home/categorizeNudge.test.ts | 23 + src/features/home/categorizeNudge.ts | 8 + src/features/home/netWorthComposition.test.ts | 32 + src/features/home/netWorthComposition.ts | 25 + src/features/home/recurring.test.ts | 61 ++ src/features/home/recurring.ts | 45 ++ src/features/home/strings.ts | 40 +- src/features/home/whereItWent.test.ts | 49 ++ src/features/home/whereItWent.ts | 40 ++ src/test/fixtures.ts | 12 + src/test/handlers.ts | 1 + 17 files changed, 853 insertions(+), 212 deletions(-) create mode 100644 src/features/home/cashFlowTrend.test.ts create mode 100644 src/features/home/cashFlowTrend.ts create mode 100644 src/features/home/categorizeNudge.test.ts create mode 100644 src/features/home/categorizeNudge.ts create mode 100644 src/features/home/netWorthComposition.test.ts create mode 100644 src/features/home/netWorthComposition.ts create mode 100644 src/features/home/recurring.test.ts create mode 100644 src/features/home/recurring.ts create mode 100644 src/features/home/whereItWent.test.ts create mode 100644 src/features/home/whereItWent.ts diff --git a/src/api/hooks/reads.ts b/src/api/hooks/reads.ts index cd7bb10..c902565 100644 --- a/src/api/hooks/reads.ts +++ b/src/api/hooks/reads.ts @@ -39,6 +39,11 @@ export const useAnalyzeToday = () => { }); }; +/** All-time analyze (adds the `months` trend series) — powers the home + * dashboard's 6-month cash-flow mini chart. */ +export const useAnalyzeAll = () => + useQuery({ queryKey: keys.analyzeAll, queryFn: () => apiGet("/analyze?period=all", AnalyzeSchema) }); + export const useBudget = () => useQuery({ queryKey: keys.budget, queryFn: () => apiGet("/budget", BudgetSchema) }); export interface TransactionsParams { diff --git a/src/api/keys.ts b/src/api/keys.ts index 9113483..4106325 100644 --- a/src/api/keys.ts +++ b/src/api/keys.ts @@ -2,6 +2,7 @@ export const keys = { networth: ["networth"] as const, analyzeMonth: ["analyze", "month"] as const, analyzeToday: (d: string) => ["analyze", "today", d] as const, + analyzeAll: ["analyze", "all"] as const, budget: ["budget"] as const, transactions: (p: string) => ["transactions", p] as const, categories: ["categories"] as const, diff --git a/src/features/home/DashboardView.tsx b/src/features/home/DashboardView.tsx index 9ecd053..d6c0683 100644 --- a/src/features/home/DashboardView.tsx +++ b/src/features/home/DashboardView.tsx @@ -2,13 +2,40 @@ import * as React from "react"; import Link from "next/link"; -import { Skeleton, ProgressCircleRoot, ProgressCircleTrack, ProgressCircleRange } from "@seed-design/react"; -import { Card, AmountToggle, HideAmountsToggle, IconChip, SectionHeader, EmptyState, SyncButton, categoryStyle } from "../../ds"; -import { tugrikShortRaw } from "../../ds/money"; -import { useNetWorth, useAnalyzeMonth, useAnalyzeToday, useBudget } from "../../api/hooks/reads"; -import { buildHome, type HomeData, type LedgerRow as LedgerRowData } from "./buildHome"; +import { Skeleton } from "@seed-design/react"; +import { + Card, + IconChip, + SectionHeader, + EmptyState, + Icon, + HideAmountsToggle, + SyncButton, + categoryStyle, +} from "../../ds"; +import type { IconName } from "../../ds/icons"; +import { AmountToggle } from "../../ds/AmountToggle"; +import { tugrik, tugrikShort, tugrikShortRaw } from "../../ds/money"; +import { + useNetWorth, + useAnalyzeMonth, + useAnalyzeAll, + useBudget, + useSubscriptions, + useTransactions, +} from "../../api/hooks/reads"; +import type { Named } from "../../api/schemas/analyze"; +import type { Subscription } from "../../api/schemas/subscription"; +import { buildHome, dec, type HomeData } from "./buildHome"; +import { buildTrendMonths, buildCashFlowVerdict, type TrendMonth } from "./cashFlowTrend"; +import { topExpenseCategories, topExpensePayees, shareOf } from "./whereItWent"; +import { recurringMonthlyTotal, detectRecurringMerchants } from "./recurring"; +import { buildNetWorthComposition } from "./netWorthComposition"; +import { countUncategorized } from "./categorizeNudge"; import { sample } from "./sample"; import { homeStrings as s } from "./strings"; +import { monthRange } from "../accounting/monthRange"; +import { useHiddenAmounts } from "../accounting/useHiddenAmounts"; /** A number that's redacted with a Seed skeleton block while loading, and a * tap-to-reveal `AmountToggle` once real data has arrived. Mirrors iOS's @@ -42,87 +69,187 @@ function TugrikCircle({ bg, fg }: { bg: string; fg: string }) { ); } -/** One Орлого/Зарлага row: an IconChip (income → the wallet/green "income" - * style; expense → the payee's category style, falling back to a neutral - * receipt when unknown) + the payee name + a trailing colored amount. - * Mirrors TransactionList's TxnRow pattern. */ -function LedgerEntryRow({ row, income }: { row: LedgerRowData; income: boolean }) { - const cat = categoryStyle(income ? undefined : row.date, income); +/** Compact inline-SVG 6-month in-vs-out chart for the cash-flow hero. No + * chart deps — two bars per month (income / expense), a muted baseline, and + * short month labels. Sits on the hero's fixed light-green brand card, so + * colors are fixed hex (not theme tokens) to stay legible in both themes. */ +function CashFlowMiniChart({ months }: { months: TrendMonth[] }) { + const max = Math.max(1, ...months.flatMap((m) => [m.income, m.expense])); + const W = 300; + const H = 78; + const base = H - 14; + const top = 6; + const groupW = W / months.length; + const barW = Math.min(16, groupW / 3.2); + return ( -
-
- +
+
+ + + {s.income} + + + + {s.expense} + +
+ + + {months.map((m, i) => { + const cx = i * groupW + groupW / 2; + const incomeH = (m.income / max) * (base - top); + const expenseH = (m.expense / max) * (base - top); + return ( + + + + + {m.label} + + + ); + })} + +
+ ); +} + +/** Thin proportional bar behind a category/merchant row's amount. */ +function ShareBar({ pct, color }: { pct: number; color: string }) { + return ( +
+
+
+ ); +} + +function CategoryShareRow({ item, items }: { item: Named; items: Named[] }) { + const style = categoryStyle(item.name); + const pct = shareOf(item, items) * 100; + return ( +
+ +
+
+ + {style.name} + + {tugrik(item.total)} +
+ +
+
+ ); +} + +function MerchantShareRow({ item, items }: { item: Named; items: Named[] }) { + const pct = shareOf(item, items) * 100; + return ( +
+
- {row.date} + {item.name} + {tugrik(item.total)}
- - {income ? "+" : "−"} - {row.title} - +
); } -/** Skeleton placeholder for a ledger row, shaped like `LedgerEntryRow` so the - * layout doesn't jump once real data (or the sample fallback) arrives — - * avoids flashing the sample payee/amount as if it were a real loaded row. */ -function LedgerRowSkeleton() { +/** One subscription/bill row on the recurring card. */ +function RecurringRow({ sub, icon }: { sub: Subscription; icon: IconName }) { return (
- - + +
+ + {sub.label} + + {sub.cadence} +
+ {tugrik(sub.monthly)}
); } -/** The Нүүр (home) dashboard: header, safe-to-spend hero, daily-limit + - * top budgets, this-month spend, and recent income/expense. Figures come - * from `buildHome` (real where the backend has them, `sample` otherwise). - * Ported from ios/Mercury/Features/Home/DashboardView.swift. */ +function Dot({ color }: { color: string }) { + return ; +} + +/** The Нүүр (home) "money clarity" dashboard: a slim categorize-nudge banner, + * the safe-to-spend + cash-flow hero, where-your-money-went, recurring & + * subscriptions, and net worth + composition. Rebuilt per the mercury-rethink + * product memo — from "did I stay under my limit?" to "where is my money + * going, and am I okay?". Figures come from `buildHome` (safe-to-spend math, + * unchanged) plus the small aggregation helpers in this directory; `sample` + * is the pre-connection / empty-account fallback for the hero only. */ export function DashboardView() { const netWorthQ = useNetWorth(); const monthQ = useAnalyzeMonth(); - const todayQ = useAnalyzeToday(); const budgetQ = useBudget(); + const trendQ = useAnalyzeAll(); + const subsQ = useSubscriptions(); + const monthTxnRange = React.useMemo(() => monthRange(0), []); + const monthTxnsQ = useTransactions(monthTxnRange); - const loading = netWorthQ.isLoading || monthQ.isLoading || todayQ.isLoading || budgetQ.isLoading; + // Subscribes this component to the global hide-amounts flag so every + // `tugrik()`/`tugrikShort()` call below (which read the flag internally, + // but don't themselves trigger a re-render) reflects a live toggle. + useHiddenAmounts(); - const built = buildHome({ - netWorth: netWorthQ.data, - month: monthQ.data, - today: todayQ.data, - budget: budgetQ.data, - }); + const heroLoading = netWorthQ.isLoading || monthQ.isLoading || budgetQ.isLoading; + + const built = buildHome({ netWorth: netWorthQ.data, month: monthQ.data, today: undefined, budget: budgetQ.data }); const data: HomeData = built ?? sample; + const hasRealMonth = built !== null; const budgetDenominator = data.availableBudget; const fraction = budgetDenominator > 0 ? Math.min(1, data.monthlyExpense / budgetDenominator) : 0; - const dailyFraction = data.dailyLimitTotal > 0 ? Math.min(1, data.dailyLimitUsed / data.dailyLimitTotal) : 0; - // Genuinely sparse month: real backend data loaded (`built` succeeded, so - // this isn't the full pre-connection sample), but no discretionary spend - // and no real income/expense payees this month — show a designed empty - // state instead of the sample ledger placeholders standing in as if real. - const noRealPayees = - (monthQ.data?.incomePayees?.length ?? 0) === 0 && (monthQ.data?.expensePayees?.length ?? 0) === 0; - const sparseMonth = built !== null && data.monthlyExpense === 0 && noRealPayees; + const trendMonths = buildTrendMonths(trendQ.data?.months); + const verdict = buildCashFlowVerdict(dec(monthQ.data?.income), dec(monthQ.data?.expense)); + + const monthTxns = monthTxnsQ.data ?? []; + const uncategorizedCount = countUncategorized(monthTxns); + + const topCategories = topExpenseCategories(monthQ.data?.expenseCategories, 5); + const topMerchants = topExpensePayees(monthQ.data?.expensePayees, 5); + const whereItWentLoading = monthQ.isLoading; + const hasWhereItWent = topCategories.length > 0 || topMerchants.length > 0; + + const subs = subsQ.data?.subscriptions ?? []; + const bills = subsQ.data?.bills ?? []; + const recurringTotal = recurringMonthlyTotal(subs, bills); + const recurringLoading = subsQ.isLoading; + const knownMatchKeys = React.useMemo( + () => new Set([...subs, ...bills].map((sub) => sub.matchKey).filter((k): k is string => !!k)), + [subs, bills], + ); + const detected = detectRecurringMerchants(monthTxns, knownMatchKeys); + + const composition = buildNetWorthComposition(netWorthQ.data); + const netWorthLoading = netWorthQ.isLoading; + const compTotal = composition.bankTotal + composition.manualTotal; + const bankPct = compTotal > 0 ? (composition.bankTotal / compTotal) * 100 : 0; return (
@@ -134,165 +261,275 @@ export function DashboardView() {
-
- {/* Safe-to-spend hero (green): how much is left to spend this month - after committed obligations and what's already been spent. */} - -
- -
- {data.overspent ? s.overspent : s.safeToSpend} - - - -
-
-
0 && ( + -
-
-
- {s.spent} - - - / {tugrikShortRaw(budgetDenominator)} - -
- - - {/* Daily limit + top budgets (blue). */} - -
- - - - -
- {s.dailyLimit} - - - {" / "} - - +
+ + {s.nudge(uncategorizedCount)}
-
-
- {s.todaySpent}: -
-
- {data.budgets.map((b) => ( -
- {b.name} - - - - - / {b.range} - -
- ))} - - - {/* This month's spend (white). */} - - {s.thisMonth} -
- {loading ? ( - - ) : ( - - {tugrikShortRaw(data.monthlyExpense)} - - )} - - {s.expenseLabel} - -
-
- - {s.viewAll} › - -
-
+ {s.nudgeCta} › + + + )} - {/* Recent income / expense ledgers. */} -
- {loading ? ( - <> - - - - - - - - - - ) : sparseMonth ? ( - +
+ {/* 1. Safe-to-spend + cash-flow hero. */} + +
+ +
+ {data.overspent ? s.overspent : s.safeToSpend} + + + +
+
+
+
+
+
+ {s.spent} + + + / {tugrikShortRaw(budgetDenominator)} + +
+ + {hasRealMonth && ( + <> +
+ {trendMonths.length > 0 && } +
0 ? 10 : 0, fontSize: 13, fontWeight: 700 }}> + + {verdict.positive ? s.verdictPositive : s.verdictNegative(tugrikShort(verdict.diff))} + +
+ + )} + + + {/* 2. Хаана зарцуулсан бэ? — top categories + top merchants. */} + + + {whereItWentLoading ? ( +
+ + + +
+ ) : !hasWhereItWent ? ( -
- ) : ( - <> - - - {data.income.map((row, i) => ( - + ) : ( + <> + {topCategories.length > 0 && ( +
+ + {s.categoriesLabel} + + {topCategories.map((c) => ( + + ))} +
+ )} + {topMerchants.length > 0 && ( +
+ + {s.merchantsLabel} + + {topMerchants.map((p) => ( + + ))} +
+ )} + + )} +
+ + {/* 3. Тогтмол төлбөр — recurring & subscriptions. */} + + + {recurringLoading ? ( + + ) : ( +
+ {tugrik(recurringTotal)} + {s.perMonth} +
+ )} + {!recurringLoading && subs.length === 0 && bills.length === 0 ? ( + + ) : ( + !recurringLoading && ( +
+ {subs.map((sub) => ( + + ))} + {bills.map((bill) => ( + + ))} +
+ ) + )} + {!recurringLoading && detected.length > 0 && ( +
+ + {s.detectedRecurringLabel} + + {detected.slice(0, 3).map((d) => ( +
+ +
+ + {d.label} + + + {s.detectedTimes(d.count)} + +
+ {tugrik(d.total)} +
))} - - - - {data.expense.map((row, i) => ( - - ))} - - - )} -
+
+ )} + + + {/* 4. Цэвэр хөрөнгө — net worth + composition. */} + + + {netWorthLoading ? ( + + ) : ( + {tugrik(composition.total)} + )} + {!netWorthLoading && compTotal > 0 && ( + <> +
+
+
+
+
+ + + {s.bankAccountsLabel} · {tugrik(composition.bankTotal)} + + + + {s.manualAssetsLabel} · {tugrik(composition.manualTotal)} + +
+ + )} + {!netWorthLoading && composition.liabilities > 0 && ( +
+ {s.liabilitiesLabel}: {tugrik(composition.liabilities)} +
+ )} + + {s.viewAssets} + +
); diff --git a/src/features/home/buildHome.ts b/src/features/home/buildHome.ts index 8596591..33e9d8c 100644 --- a/src/features/home/buildHome.ts +++ b/src/features/home/buildHome.ts @@ -7,7 +7,7 @@ import { sample } from "./sample"; /** Parse a backend decimal string ("900000") into a number. Mirrors * `Decimal(string:) ?? 0` in HomeData.swift's local `dec` helper. */ -function dec(s?: string | null): number { +export function dec(s?: string | null): number { if (!s) return 0; const n = Number(s); return Number.isFinite(n) ? n : 0; diff --git a/src/features/home/cashFlowTrend.test.ts b/src/features/home/cashFlowTrend.test.ts new file mode 100644 index 0000000..a3adcad --- /dev/null +++ b/src/features/home/cashFlowTrend.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from "vitest"; +import { buildTrendMonths, buildCashFlowVerdict } from "./cashFlowTrend"; + +describe("buildTrendMonths", () => { + it("takes the last N months and formats short Mongolian labels", () => { + const months = [ + { month: "2026-01", income: "1", expense: "1" }, + { month: "2026-02", income: "1", expense: "1" }, + { month: "2026-03", income: "1", expense: "1" }, + { month: "2026-08", income: "2650000", expense: "2400000" }, + ]; + const out = buildTrendMonths(months, 2); + expect(out).toHaveLength(2); + expect(out[1]).toEqual({ month: "2026-08", label: "8-р сар", income: 2650000, expense: 2400000 }); + }); + + it("returns [] when months is missing", () => { + expect(buildTrendMonths(null)).toEqual([]); + expect(buildTrendMonths(undefined)).toEqual([]); + }); +}); + +describe("buildCashFlowVerdict", () => { + it("is positive when income covers expense", () => { + expect(buildCashFlowVerdict(5_300_000, 2_360_000)).toEqual({ positive: true, diff: 2_940_000 }); + }); + + it("is negative when expense exceeds income, diff is absolute", () => { + expect(buildCashFlowVerdict(3_070_000, 3_280_000)).toEqual({ positive: false, diff: 210_000 }); + }); + + it("treats an exact break-even as positive (not overspent)", () => { + expect(buildCashFlowVerdict(1_000, 1_000)).toEqual({ positive: true, diff: 0 }); + }); +}); diff --git a/src/features/home/cashFlowTrend.ts b/src/features/home/cashFlowTrend.ts new file mode 100644 index 0000000..3f30ad5 --- /dev/null +++ b/src/features/home/cashFlowTrend.ts @@ -0,0 +1,43 @@ +import type { Analyze } from "../../api/schemas/analyze"; +import { dec } from "./buildHome"; + +/** One month of the cash-flow mini chart. */ +export interface TrendMonth { + /** "2026-08" as returned by the backend. */ + month: string; + /** Short Mongolian label, e.g. "8-р сар". */ + label: string; + income: number; + expense: number; +} + +/** "2026-08" -> "8-р сар". Falls back to the raw string if it doesn't parse. */ +function monthShortLabel(month: string): string { + const mm = parseInt(month.split("-")[1] ?? "", 10); + return Number.isFinite(mm) && mm > 0 ? `${mm}-р сар` : month; +} + +/** Last `take` months (oldest-first) from `/analyze?period=all`'s `months` + * series, for the cash-flow hero's mini bar chart. */ +export function buildTrendMonths(months: Analyze["months"] | null | undefined, take = 6): TrendMonth[] { + const list = months ?? []; + return list.slice(-take).map((m) => ({ + month: m.month, + label: monthShortLabel(m.month), + income: dec(m.income), + expense: dec(m.expense), + })); +} + +/** Did this month's cash flow work out? Compares full income vs full expense + * (not discretionary spend) — the plain "did I spend more than I earned" + * question the memo's cash-flow callout is built around. */ +export interface CashFlowVerdict { + positive: boolean; + /** Absolute difference between income and expense. */ + diff: number; +} + +export function buildCashFlowVerdict(income: number, expense: number): CashFlowVerdict { + return { positive: income >= expense, diff: Math.abs(income - expense) }; +} diff --git a/src/features/home/categorizeNudge.test.ts b/src/features/home/categorizeNudge.test.ts new file mode 100644 index 0000000..27dbe7f --- /dev/null +++ b/src/features/home/categorizeNudge.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from "vitest"; +import { countUncategorized } from "./categorizeNudge"; +import type { Txn } from "../../api/schemas/transaction"; + +function txn(overrides: Partial): Txn { + return { date: "2026-08-01", amount: "1000", direction: "debit", category: "Хоол", title: "т", accountId: 1, ...overrides }; +} + +describe("countUncategorized", () => { + it("counts rows with an empty category", () => { + const txns = [txn({ category: "" }), txn({ category: "Хоол" }), txn({ category: "" })]; + expect(countUncategorized(txns)).toBe(2); + }); + + it("ignores an uncategorized row with neither matchKey nor title", () => { + const txns = [txn({ category: "", title: "", matchKey: null })]; + expect(countUncategorized(txns)).toBe(0); + }); + + it("is 0 when everything is categorized", () => { + expect(countUncategorized([txn({ category: "Хоол" })])).toBe(0); + }); +}); diff --git a/src/features/home/categorizeNudge.ts b/src/features/home/categorizeNudge.ts new file mode 100644 index 0000000..ae76f13 --- /dev/null +++ b/src/features/home/categorizeNudge.ts @@ -0,0 +1,8 @@ +import type { Txn } from "../../api/schemas/transaction"; + +/** Transactions with no category assigned yet (mirrors TransactionList's + * `hasUncategorized` check) — the count that drives the home screen's + * "N гүйлгээ ангилаагүй байна" nudge, the memo's keystone module. */ +export function countUncategorized(txns: Txn[]): number { + return txns.filter((t) => !t.category && (t.matchKey || t.title)).length; +} diff --git a/src/features/home/netWorthComposition.test.ts b/src/features/home/netWorthComposition.test.ts new file mode 100644 index 0000000..7886eaf --- /dev/null +++ b/src/features/home/netWorthComposition.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from "vitest"; +import { buildNetWorthComposition } from "./netWorthComposition"; + +describe("buildNetWorthComposition", () => { + it("splits bank accounts from the manual-asset remainder", () => { + const out = buildNetWorthComposition({ + assets: "60000000", + liabilities: "5000000", + total: "55000000", + accounts: [ + { accountId: 1, bank: "khan", accountNumber: "***1", currency: "MNT", isLiability: false, balance: "10000000" }, + { accountId: 2, bank: "tdb", accountNumber: "***2", currency: "MNT", isLiability: true, balance: "5000000" }, + ], + }); + expect(out).toEqual({ total: 55_000_000, liabilities: 5_000_000, bankTotal: 10_000_000, manualTotal: 50_000_000 }); + }); + + it("floors manualTotal at 0 when bank total exceeds assets", () => { + const out = buildNetWorthComposition({ + assets: "5000000", + liabilities: "0", + total: "5000000", + accounts: [{ accountId: 1, bank: "khan", accountNumber: "***1", currency: "MNT", isLiability: false, balance: "9000000" }], + }); + expect(out.manualTotal).toBe(0); + }); + + it("handles missing input", () => { + expect(buildNetWorthComposition(null)).toEqual({ total: 0, liabilities: 0, bankTotal: 0, manualTotal: 0 }); + expect(buildNetWorthComposition(undefined)).toEqual({ total: 0, liabilities: 0, bankTotal: 0, manualTotal: 0 }); + }); +}); diff --git a/src/features/home/netWorthComposition.ts b/src/features/home/netWorthComposition.ts new file mode 100644 index 0000000..1ec4877 --- /dev/null +++ b/src/features/home/netWorthComposition.ts @@ -0,0 +1,25 @@ +import type { NetWorth } from "../../api/schemas/networth"; +import { dec } from "./buildHome"; + +export interface NetWorthComposition { + total: number; + liabilities: number; + /** Sum of linked bank-account balances (`/networth.accounts`). */ + bankTotal: number; + /** Assets not covered by a linked bank account — manually tracked physical + * assets (Хөрөнгө), approximated as assets minus bank accounts, floored at 0. */ + manualTotal: number; +} + +/** Approximates net-worth composition from `/networth` alone (no separate + * manual-assets fetch needed): bank accounts vs "everything else" in assets. */ +export function buildNetWorthComposition(netWorth: NetWorth | null | undefined): NetWorthComposition { + const total = dec(netWorth?.total); + const assets = dec(netWorth?.assets); + const liabilities = dec(netWorth?.liabilities); + const bankTotal = (netWorth?.accounts ?? []) + .filter((a) => !a.isLiability) + .reduce((sum, a) => sum + dec(a.balance), 0); + const manualTotal = Math.max(0, assets - bankTotal); + return { total, liabilities, bankTotal, manualTotal }; +} diff --git a/src/features/home/recurring.test.ts b/src/features/home/recurring.test.ts new file mode 100644 index 0000000..09133b4 --- /dev/null +++ b/src/features/home/recurring.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from "vitest"; +import { recurringMonthlyTotal, detectRecurringMerchants } from "./recurring"; +import type { Txn } from "../../api/schemas/transaction"; + +function txn(overrides: Partial): Txn { + return { + date: "2026-08-01", + amount: "10000", + direction: "debit", + category: "", + title: "ТОКИ ББСБ", + accountId: 1, + transfer: false, + matchKey: "toki", + ...overrides, + }; +} + +describe("recurringMonthlyTotal", () => { + it("sums subscriptions and bills monthly amounts", () => { + const subs = [{ label: "Netflix", amount: "12900", monthly: "12900", cadence: "monthly" }] as any; + const bills = [{ label: "Цахилгаан", amount: "50000", monthly: "50000", cadence: "monthly" }] as any; + expect(recurringMonthlyTotal(subs, bills)).toBe(62900); + }); + + it("is 0 for empty lists", () => { + expect(recurringMonthlyTotal([], [])).toBe(0); + }); +}); + +describe("detectRecurringMerchants", () => { + it("flags a merchant seen 3+ times and not already known", () => { + const txns = [txn({}), txn({}), txn({})]; + const out = detectRecurringMerchants(txns, new Set()); + expect(out).toHaveLength(1); + expect(out[0]).toMatchObject({ key: "toki", count: 3, total: 30000 }); + }); + + it("ignores a merchant already tracked as a subscription/bill", () => { + const txns = [txn({}), txn({}), txn({})]; + const out = detectRecurringMerchants(txns, new Set(["toki"])); + expect(out).toHaveLength(0); + }); + + it("excludes transfers and income rows", () => { + const txns = [ + txn({ transfer: true }), + txn({ transfer: true }), + txn({ transfer: true }), + txn({ direction: "income" }), + txn({ direction: "income" }), + txn({ direction: "income" }), + ]; + expect(detectRecurringMerchants(txns, new Set())).toHaveLength(0); + }); + + it("does not surface merchants seen fewer than minCount times", () => { + const txns = [txn({}), txn({})]; + expect(detectRecurringMerchants(txns, new Set())).toHaveLength(0); + }); +}); diff --git a/src/features/home/recurring.ts b/src/features/home/recurring.ts new file mode 100644 index 0000000..5f3d15c --- /dev/null +++ b/src/features/home/recurring.ts @@ -0,0 +1,45 @@ +import type { Subscription } from "../../api/schemas/subscription"; +import type { Txn } from "../../api/schemas/transaction"; +import { dec } from "./buildHome"; + +/** Sum of every subscription's + bill's monthly-equivalent amount — the "what + * am I committed to paying every month" total for the recurring card. */ +export function recurringMonthlyTotal(subscriptions: Subscription[], bills: Subscription[]): number { + return [...subscriptions, ...bills].reduce((sum, item) => sum + dec(item.monthly), 0); +} + +export interface DetectedRecurring { + key: string; + label: string; + count: number; + total: number; +} + +/** Merchants seen `minCount`+ times this month that aren't already tracked as + * a subscription/bill — a lightweight stand-in for real recurring detection, + * scoped to what a single month of `/transactions` can show. Transfers and + * income rows are excluded. */ +export function detectRecurringMerchants( + txns: Txn[], + knownMatchKeys: ReadonlySet, + minCount = 3, +): DetectedRecurring[] { + const groups = new Map(); + for (const t of txns) { + if (t.transfer === true || t.direction === "income") continue; + const key = t.matchKey || t.title; + if (!key || knownMatchKeys.has(key)) continue; + const amount = dec(t.amount); + const existing = groups.get(key); + if (existing) { + existing.count += 1; + existing.total += amount; + } else { + groups.set(key, { label: t.title || key, count: 1, total: amount }); + } + } + return Array.from(groups.entries()) + .filter(([, v]) => v.count >= minCount) + .map(([key, v]) => ({ key, ...v })) + .sort((a, b) => b.total - a.total); +} diff --git a/src/features/home/strings.ts b/src/features/home/strings.ts index 3e95cd4..fb23a9a 100644 --- a/src/features/home/strings.ts +++ b/src/features/home/strings.ts @@ -1,18 +1,42 @@ -// Home dashboard copy, ported verbatim from -// ios/Mercury/Features/Home/DashboardView.swift. +// Home dashboard copy. The "money clarity" rebuild (mercury-rethink memo): +// cash-flow hero, where-it-went, recurring, net worth, categorize nudge. export const homeStrings = { wordmark: "MERCURY", + hideAmounts: "Мөнгөн дүн нуух", + + // 1. Safe-to-spend + cash-flow hero. safeToSpend: "Энэ сар зарцуулж болох", overspent: "Төсвөөс хэтэрсэн", spent: "Зарцуулсан", - dailyLimit: "Өнөөдрийн лимит", - thisMonth: "Энэ сард", - expenseLabel: "зарлага", - viewAll: "Бүгдийг харах", - hideAmounts: "Мөнгөн дүн нуух", income: "Орлого", expense: "Зарлага", - todaySpent: "Өнөөдөр зарцуулсан", + verdictPositive: "Энэ сар орлого зарлагаа даасан", + verdictNegative: (amount: string) => `Энэ сар ${amount}-р илүү зарцуулсан`, + trendAria: "Сүүлийн 6 сарын орлого, зарлагын харьцуулалт", + + // Categorize nudge. + nudge: (count: number) => `${count} гүйлгээ ангилаагүй байна`, + nudgeCta: "Ангилах", + + // 2. Хаана зарцуулсан бэ? + whereWentTitle: "Хаана зарцуулсан бэ?", + categoriesLabel: "Ангилал", + merchantsLabel: "Худалдагч", noSpendTitle: "Энэ сар хараахан зарлага алга", noSpendHint: "Гүйлгээ хийгдмэгц энд харагдана.", + + // 3. Тогтмол төлбөр. + recurringTitle: "Тогтмол төлбөр", + perMonth: "/ сар", + detectedRecurringLabel: "Илэрсэн тогтмол", + detectedTimes: (count: number) => `Энэ сард ${count} удаа`, + noSubsTitle: "Тогтмол төлбөр алга", + noSubsHint: "Захиалга, дансны төлбөрүүд энд харагдана.", + + // 4. Цэвэр хөрөнгө. + netWorthTitle: "Цэвэр хөрөнгө", + bankAccountsLabel: "Банкны данс", + manualAssetsLabel: "Бусад хөрөнгө", + liabilitiesLabel: "Өр төлбөр", + viewAssets: "Хөрөнгийн жагсаалт", } as const; diff --git a/src/features/home/whereItWent.test.ts b/src/features/home/whereItWent.test.ts new file mode 100644 index 0000000..ef952fb --- /dev/null +++ b/src/features/home/whereItWent.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from "vitest"; +import { topExpenseCategories, topExpensePayees, shareOf } from "./whereItWent"; + +describe("topExpenseCategories", () => { + it("sorts by total desc and caps at the limit", () => { + const cats = [ + { name: "coffee", count: 5, total: "50000" }, + { name: "groceries", count: 10, total: "300000" }, + { name: "transport", count: 3, total: "80000" }, + ]; + expect(topExpenseCategories(cats, 2).map((c) => c.name)).toEqual(["groceries", "transport"]); + }); + + it("excludes transfers and uncategorized (blank name) noise", () => { + const cats = [ + { name: "transfers", count: 4, total: "900000" }, + { name: "", count: 12, total: "700000" }, + { name: "coffee", count: 5, total: "50000" }, + ]; + expect(topExpenseCategories(cats).map((c) => c.name)).toEqual(["coffee"]); + }); + + it("handles missing input", () => { + expect(topExpenseCategories(null)).toEqual([]); + expect(topExpenseCategories(undefined)).toEqual([]); + }); +}); + +describe("topExpensePayees", () => { + it("drops blank-name payees and sorts desc", () => { + const payees = [ + { name: "", count: 1, total: "999999" }, + { name: "ToKI ББСБ", count: 9, total: "450000" }, + { name: "Coffee lab", count: 4, total: "60000" }, + ]; + expect(topExpensePayees(payees).map((p) => p.name)).toEqual(["ToKI ББСБ", "Coffee lab"]); + }); +}); + +describe("shareOf", () => { + it("computes 0..1 share against the max in the list", () => { + const items = [ + { name: "a", count: 1, total: "100" }, + { name: "b", count: 1, total: "50" }, + ]; + expect(shareOf(items[0], items)).toBe(1); + expect(shareOf(items[1], items)).toBe(0.5); + }); +}); diff --git a/src/features/home/whereItWent.ts b/src/features/home/whereItWent.ts new file mode 100644 index 0000000..ad0c349 --- /dev/null +++ b/src/features/home/whereItWent.ts @@ -0,0 +1,40 @@ +import type { Named } from "../../api/schemas/analyze"; +import { dec } from "./buildHome"; + +// Category (and merchant-name) keys that are noise for "where did my money +// go" — self-transfer churn and the uncategorized bucket. An empty category +// name is the backend's uncategorized bucket, not a real spending category; +// the user has already told the app self-transfer churn isn't insight (see +// memory: fms-analysis-prefs). +const NOISE_CATEGORIES = new Set(["", "transfers", "uncategorized", "бусад", "шилжүүлэг"]); + +function isNoiseCategory(name: string | null | undefined): boolean { + return NOISE_CATEGORIES.has((name ?? "").trim().toLowerCase()); +} + +/** Top `limit` expense categories by total, transfers/uncategorized noise + * filtered out, biggest first. */ +export function topExpenseCategories(categories: Named[] | null | undefined, limit = 5): Named[] { + return (categories ?? []) + .filter((c) => !isNoiseCategory(c.name)) + .slice() + .sort((a, b) => dec(b.total) - dec(a.total)) + .slice(0, limit); +} + +/** Top `limit` expense merchants/payees by total, blank names dropped, + * biggest first. */ +export function topExpensePayees(payees: Named[] | null | undefined, limit = 5): Named[] { + return (payees ?? []) + .filter((p) => p.name.trim().length > 0) + .slice() + .sort((a, b) => dec(b.total) - dec(a.total)) + .slice(0, limit); +} + +/** Share (0..1) of `item` against the largest total in `items` — drives each + * row's thin share bar width. */ +export function shareOf(item: Named, items: Named[]): number { + const max = Math.max(1, ...items.map((i) => dec(i.total))); + return Math.min(1, dec(item.total) / max); +} diff --git a/src/test/fixtures.ts b/src/test/fixtures.ts index 679b1db..2cd760d 100644 --- a/src/test/fixtures.ts +++ b/src/test/fixtures.ts @@ -21,6 +21,18 @@ export const analyzeToday = { from: "2026-08-22", to: "2026-08-22", income: "0", expenseCategories: [{ name: "Хоол", count: 1, total: "52000" }], incomePayees: [], expensePayees: [{ name: "худалдан авалт", count: 1, total: "52000" }] }; +// /analyze?period=all — same shape as analyzeMonth, plus the 6-month `months` +// trend series the home dashboard's cash-flow mini chart reads. +export const analyzeAll = { ...analyzeMonth, + months: [ + { month: "2026-03", income: "2500000", expense: "2100000" }, + { month: "2026-04", income: "2650000", expense: "2400000" }, + { month: "2026-05", income: "2800000", expense: "3400000" }, + { month: "2026-06", income: "3070000", expense: "3280000" }, + { month: "2026-07", income: "5300000", expense: "2360000" }, + { month: "2026-08", income: analyzeMonth.income, expense: analyzeMonth.expense }, + ] }; + export const category = { name: "Хоол", kind: "expense", depth: 0, icon: null }; export const categories = [category]; diff --git a/src/test/handlers.ts b/src/test/handlers.ts index c6636a8..e7a5d79 100644 --- a/src/test/handlers.ts +++ b/src/test/handlers.ts @@ -16,6 +16,7 @@ export const handlers = [ // vs useAnalyzeToday in src/api/hooks/reads.ts. http.get("/api/v1/analyze", ({ request }) => { const url = new URL(request.url); + if (url.searchParams.get("period") === "all") return HttpResponse.json(fixtures.analyzeAll); const isToday = url.searchParams.has("from") || url.searchParams.has("to"); return HttpResponse.json(isToday ? fixtures.analyzeToday : fixtures.analyzeMonth); }),