diff --git a/src/app/(app)/home/page.tsx b/src/app/(app)/home/page.tsx
index 63682dd..e20e990 100644
--- a/src/app/(app)/home/page.tsx
+++ b/src/app/(app)/home/page.tsx
@@ -1,6 +1,5 @@
-import { t } from "@/i18n/common";
+import { DashboardView } from "@/features/home/DashboardView";
-// Placeholder — Task 9 replaces this with the real dashboard.
export default function HomePage() {
- return
+
+
+ {/* 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}
+
+
+ {" / "}
+
+
+
+
+
+
+ {data.budgets.map((b) => (
+
+
{b.name}
+
+
+
+
+ / {b.range}
+
+
+ ))}
+
+
+ {/* This month's spend (white). */}
+
+ {s.thisMonth}
+
+ {loading ? (
+
+ ) : (
+
+ {tugrikShortRaw(data.monthlyExpense)}
+
+ )}
+
+ {s.expenseLabel}
+
+
+
+
+ {s.viewAll} ›
+
+
+
+
+ {/* Recent income / expense ledgers. */}
+
+
+ {s.income}
+ {data.income.map((row, i) => (
+
+ {row.title}
+ {row.date}
+
+ ))}
+
+
+ {s.expense}
+ {data.expense.map((row, i) => (
+
+ {row.title}
+ {row.date}
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/features/home/buildHome.test.ts b/src/features/home/buildHome.test.ts
new file mode 100644
index 0000000..be14210
--- /dev/null
+++ b/src/features/home/buildHome.test.ts
@@ -0,0 +1,97 @@
+import { describe, it, expect } from "vitest";
+import { buildHome } from "./buildHome";
+import * as fx from "../../test/fixtures";
+
+describe("buildHome", () => {
+ it("uses monthLimit when set and discretionary spend", () => {
+ const h = buildHome({ netWorth: fx.netWorth, month: fx.analyzeMonth, today: fx.analyzeMonth, budget: fx.budget })!;
+ expect(h.availableBudget).toBe(900000); // monthLimit
+ expect(h.monthlyExpense).toBe(800000); // discretionaryExpense
+ expect(h.safeToSpend).toBe(100000);
+ expect(h.overspent).toBe(false);
+ expect(h.totalBalance).toBe(452200);
+ expect(h.todayExpense).toBe(1200000); // today's `expense` field (not discretionary)
+ });
+
+ it("returns null for empty account", () =>
+ expect(
+ buildHome({
+ netWorth: { assets: "0", liabilities: "0", total: "0" },
+ month: { ...fx.analyzeMonth, expense: "0", discretionaryExpense: "0", expensePayees: [] },
+ today: null,
+ budget: null,
+ }),
+ ).toBeNull());
+
+ it("returns null when month is missing", () =>
+ expect(buildHome({ netWorth: fx.netWorth, month: null, today: null, budget: null })).toBeNull());
+
+ it("falls back to availableIncome when monthLimit is 0", () => {
+ const h = buildHome({
+ netWorth: fx.netWorth,
+ month: fx.analyzeMonth,
+ today: null,
+ budget: { ...fx.budget, monthLimit: "0" },
+ })!;
+ expect(h.availableBudget).toBe(2036795); // availableIncome
+ });
+
+ it("falls back to expense when discretionaryExpense is absent", () => {
+ const h = buildHome({
+ netWorth: fx.netWorth,
+ month: { ...fx.analyzeMonth, discretionaryExpense: null },
+ today: null,
+ budget: fx.budget,
+ })!;
+ expect(h.monthlyExpense).toBe(1200000); // expense
+ });
+
+ it("flags overspent when monthlyExpense exceeds availableBudget", () => {
+ const h = buildHome({
+ netWorth: fx.netWorth,
+ month: { ...fx.analyzeMonth, discretionaryExpense: "1000000" },
+ today: null,
+ budget: { ...fx.budget, monthLimit: "900000" },
+ })!;
+ expect(h.overspent).toBe(true);
+ expect(h.safeToSpend).toBe(0); // floored at 0, not negative
+ });
+
+ it("takes the top-3 day-horizon budget rows with limit > 0, sorted desc", () => {
+ const h = buildHome({
+ netWorth: fx.netWorth,
+ month: fx.analyzeMonth,
+ today: null,
+ budget: {
+ ...fx.budget,
+ report: {
+ ...fx.budget.report,
+ day: {
+ overallSpent: "15500",
+ overallLimit: "35500",
+ rows: [
+ { category: "Тээвэр", spent: "0", limit: "5000" },
+ { category: "Хоол", spent: "23700", limit: "27000" },
+ { category: "Coffee", spent: "23700", limit: "6000" },
+ { category: "Бусад", spent: "0", limit: "0" },
+ ],
+ },
+ },
+ },
+ })!;
+ expect(h.budgets).toHaveLength(3);
+ expect(h.budgets.map((b) => b.name)).toEqual(["Хоол", "Coffee", "Тээвэр"]);
+ expect(h.budgets.every((b) => b.range.includes("₮"))).toBe(true);
+ });
+
+ it("falls back to sample income/expense ledgers when payees are empty", () => {
+ const h = buildHome({
+ netWorth: fx.netWorth,
+ month: { ...fx.analyzeMonth, incomePayees: [], expensePayees: [] },
+ today: null,
+ budget: fx.budget,
+ })!;
+ expect(h.income.length).toBeGreaterThan(0);
+ expect(h.expense.length).toBeGreaterThan(0);
+ });
+});
diff --git a/src/features/home/buildHome.ts b/src/features/home/buildHome.ts
new file mode 100644
index 0000000..940aa8f
--- /dev/null
+++ b/src/features/home/buildHome.ts
@@ -0,0 +1,128 @@
+import type { NetWorth } from "../../api/schemas/networth";
+import type { Analyze } from "../../api/schemas/analyze";
+import type { Budget } from "../../api/schemas/budget";
+import { tugrik, tugrikRaw } from "../../ds/money";
+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 {
+ if (!s) return 0;
+ const n = Number(s);
+ return Number.isFinite(n) ? n : 0;
+}
+
+/** One budgeted category row on the blue limit card. */
+export interface BudgetRow {
+ name: string;
+ spent: number;
+ /** Formatted target amount, e.g. "27,000₮". */
+ range: string;
+}
+
+/** One income/expense entry under the Орлого / Зарлага cards. Field names
+ * mirror iOS's `LedgerRow` exactly: `title` holds the formatted amount,
+ * `date` holds a label (the payee name on the real-data path, a literal
+ * date string in `.sample`). */
+export interface LedgerRow {
+ title: string;
+ date: string;
+}
+
+/** Everything the dashboard renders. Populated from the backend where
+ * endpoints exist (balance, monthly spend); the rest uses representative
+ * sample values until the matching backend features land. */
+export interface HomeData {
+ totalBalance: number;
+ todayExpense: number;
+ dailyLimitUsed: number;
+ dailyLimitTotal: number;
+ budgets: BudgetRow[];
+ monthlyExpense: number;
+ /** The monthly budget denominator: the user's explicit monthly cap when
+ * set (> 0), otherwise salary-derived disposable income. */
+ availableBudget: number;
+ income: LedgerRow[];
+ expense: LedgerRow[];
+ /** What's left to spend this month, floored at 0. */
+ safeToSpend: number;
+ /** Whether monthly spend has exceeded the available budget. */
+ overspent: boolean;
+}
+
+export interface BuildHomeInput {
+ netWorth: NetWorth | null | undefined;
+ month: Analyze | null | undefined;
+ today: Analyze | null | undefined;
+ budget: Budget | null | undefined;
+}
+
+/** Build real dashboard data from the backend. Balance comes from /networth;
+ * this-month spend, the spend breakdown, and the income/expense ledgers come
+ * from /analyze (month) via real payees; today's spend from /analyze
+ * (today). Returns null when the account has no data yet, so the caller
+ * keeps `sample`. Ported EXACTLY from `HomeData.live` in
+ * ios/Mercury/Features/Home/HomeData.swift. */
+export function buildHome({ netWorth, month, today, budget }: BuildHomeInput): HomeData | null {
+ if (!month) return null;
+
+ const balance = dec(netWorth?.total);
+ // "Spent" = real discretionary spend (excludes the loan payment + transfers),
+ // so the safe-to-spend hero and the monthly figure aren't inflated by money
+ // that's already committed or just moved between people/accounts.
+ const monthlyExpense = dec(month.discretionaryExpense ?? month.expense);
+ const payees = month.expensePayees ?? [];
+ const incomePayees = month.incomePayees ?? [];
+
+ // An empty account → no real data; fall back to the sample.
+ if (balance === 0 && monthlyExpense === 0 && payees.length === 0) return null;
+
+ const todayExpense = dec(today?.expense);
+
+ // Hero budget (the "Энэ сар зарцуулж болох" denominator): honor the user's
+ // explicitly-set monthly limit when present; only fall back to salary-
+ // derived disposable income (planned income − loans − savings − subs) when
+ // no limit is set. Paired with the DISCRETIONARY monthlyExpense above (not
+ // the budget report's all-expense overallSpent), since the cap is a
+ // spending cap.
+ const monthLimit = dec(budget?.monthLimit);
+ const availableBudget = monthLimit > 0 ? monthLimit : dec(budget?.availableIncome);
+
+ // Daily limit + per-category budgets come from the budget's DAY horizon —
+ // the same source as the Төлөвлөгөө hub (real limits, no payee fallback).
+ const dayReport = budget?.report.day;
+ const dailyLimitTotal = dec(dayReport?.overallLimit);
+ const dailyLimitUsed = dec(dayReport?.overallSpent);
+ // Only the categories with a real daily limit, biggest first — a proper
+ // daily budget (e.g. Хоол хүнс, Кофе, Тээвэр), not the uncategorized bucket.
+ const budgets: BudgetRow[] = (dayReport?.rows ?? [])
+ .filter((row) => dec(row.limit) > 0)
+ .sort((a, b) => dec(b.limit) - dec(a.limit))
+ .slice(0, 3)
+ .map((row) => ({
+ name: row.category,
+ spent: dec(row.spent),
+ range: tugrikRaw(dec(row.limit)),
+ }));
+
+ const income: LedgerRow[] = incomePayees
+ .slice(0, 2)
+ .map((p) => ({ title: tugrik(dec(p.total)), date: p.name }));
+ const expense: LedgerRow[] = payees
+ .slice(0, 2)
+ .map((p) => ({ title: tugrik(dec(p.total)), date: p.name }));
+
+ return {
+ totalBalance: balance,
+ todayExpense,
+ dailyLimitUsed,
+ dailyLimitTotal,
+ budgets,
+ monthlyExpense,
+ availableBudget,
+ income: income.length === 0 ? sample.income : income,
+ expense: expense.length === 0 ? sample.expense : expense,
+ safeToSpend: Math.max(0, availableBudget - monthlyExpense),
+ overspent: monthlyExpense > availableBudget,
+ };
+}
diff --git a/src/features/home/sample.ts b/src/features/home/sample.ts
new file mode 100644
index 0000000..a0fa349
--- /dev/null
+++ b/src/features/home/sample.ts
@@ -0,0 +1,25 @@
+import type { HomeData } from "./buildHome";
+
+const monthlyExpense = 1_200_000;
+const availableBudget = 2_036_795;
+
+/** Figma sample state (also the fallback before a bank is connected). Ported
+ * verbatim from `HomeData.sample` in
+ * ios/Mercury/Features/Home/HomeData.swift. */
+export const sample: HomeData = {
+ totalBalance: 452_200,
+ todayExpense: 52_000,
+ dailyLimitUsed: 15_500,
+ dailyLimitTotal: 35_500,
+ budgets: [
+ { name: "Хоол", spent: 23_700, range: "22,000₮ - 27,000₮" },
+ { name: "Coffee", spent: 23_700, range: "4500₮ - 6000₮" },
+ { name: "Тээвэр", spent: 0, range: "5000₮" },
+ ],
+ monthlyExpense,
+ availableBudget,
+ income: [{ title: "1,345,634₮", date: "04.01" }],
+ expense: [{ title: "худалдан авалт", date: "04.01" }],
+ safeToSpend: Math.max(0, availableBudget - monthlyExpense),
+ overspent: monthlyExpense > availableBudget,
+};
diff --git a/src/features/home/strings.ts b/src/features/home/strings.ts
new file mode 100644
index 0000000..f9e398e
--- /dev/null
+++ b/src/features/home/strings.ts
@@ -0,0 +1,16 @@
+// Home dashboard copy, ported verbatim from
+// ios/Mercury/Features/Home/DashboardView.swift.
+export const homeStrings = {
+ wordmark: "MERCURY",
+ safeToSpend: "Энэ сар зарцуулж болох",
+ overspent: "Төсвөөс хэтэрсэн",
+ spent: "Зарцуулсан",
+ dailyLimit: "Өнөөдрийн лимит",
+ thisMonth: "Энэ сард",
+ expenseLabel: "зарлага",
+ viewAll: "Бүгдийг харах",
+ hideAmounts: "Мөнгөн дүн нуух",
+ income: "Орлого",
+ expense: "Зарлага",
+ todaySpent: "Өнөөдөр зарцуулсан",
+} as const;