From 762b572d309bd96ebda5034b87488aeabbc9e3d4 Mon Sep 17 00:00:00 2001 From: Munkherdene Date: Sat, 22 Aug 2026 20:53:02 +0800 Subject: [PATCH] feat(web): home dashboard + data assembly ported from iOS --- src/app/(app)/home/page.tsx | 5 +- src/features/home/DashboardView.tsx | 223 ++++++++++++++++++++++++++++ src/features/home/buildHome.test.ts | 97 ++++++++++++ src/features/home/buildHome.ts | 128 ++++++++++++++++ src/features/home/sample.ts | 25 ++++ src/features/home/strings.ts | 16 ++ 6 files changed, 491 insertions(+), 3 deletions(-) create mode 100644 src/features/home/DashboardView.tsx create mode 100644 src/features/home/buildHome.test.ts create mode 100644 src/features/home/buildHome.ts create mode 100644 src/features/home/sample.ts create mode 100644 src/features/home/strings.ts 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

{t.tabs.home}

; + return ; } diff --git a/src/features/home/DashboardView.tsx b/src/features/home/DashboardView.tsx new file mode 100644 index 0000000..2047fd9 --- /dev/null +++ b/src/features/home/DashboardView.tsx @@ -0,0 +1,223 @@ +"use client"; + +import * as React from "react"; +import Link from "next/link"; +import { Skeleton, ProgressCircleRoot, ProgressCircleTrack, ProgressCircleRange } from "@seed-design/react"; +import { Card, AmountToggle, HideAmountsToggle } from "../../ds"; +import { tugrikShortRaw } from "../../ds/money"; +import { useNetWorth, useAnalyzeMonth, useAnalyzeToday, useBudget } from "../../api/hooks/reads"; +import { buildHome, type HomeData } from "./buildHome"; +import { sample } from "./sample"; +import { homeStrings as s } from "./strings"; + +/** 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 + * `.skeleton(loading)` view modifier, which redacts the finished layout in + * place rather than swapping in a separate spinner. */ +function Amount({ value, loading, width = "72px" }: { value: number; loading: boolean; width?: string }) { + if (loading) return ; + return ; +} + +/** The circular "₮" badge used on every card (togrogCircle in DashboardView.swift). */ +function TugrikCircle({ bg, fg }: { bg: string; fg: string }) { + return ( +
+ ₮ +
+ ); +} + +/** 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. */ +export function DashboardView() { + const netWorthQ = useNetWorth(); + const monthQ = useAnalyzeMonth(); + const todayQ = useAnalyzeToday(); + const budgetQ = useBudget(); + + const loading = netWorthQ.isLoading || monthQ.isLoading || todayQ.isLoading || budgetQ.isLoading; + + const built = buildHome({ + netWorth: netWorthQ.data, + month: monthQ.data, + today: todayQ.data, + budget: budgetQ.data, + }); + const data: HomeData = built ?? sample; + + 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; + + return ( +
+
+ {s.wordmark} + +
+ + {/* 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.todaySpent}: +
+
+ {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;