-
+
+
+
+
+ {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}
-
-
-
-
-
-
-
-
{s.spent}
-
-
- / {tugrikShortRaw(budgetDenominator)}
-
-
-
-
- {/* Daily limit + top budgets (blue). */}
-
-
-
-
-
-
-
-
{s.dailyLimit}
-
-
- {" / "}
-
-
+
+
+ {s.nudge(uncategorizedCount)}
-
-
-
- {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);
}),