feat(web): home dashboard + data assembly ported from iOS

This commit is contained in:
Munkherdene 2026-08-22 20:53:02 +08:00
parent 2555e42f21
commit 762b572d30
6 changed files with 491 additions and 3 deletions

View file

@ -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 <h1 style={{ fontWeight: 700 }}>{t.tabs.home}</h1>;
return <DashboardView />;
}

View file

@ -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 <Skeleton height="1em" width={width} style={{ display: "inline-block" }} />;
return <AmountToggle value={value} />;
}
/** The circular "₮" badge used on every card (togrogCircle in DashboardView.swift). */
function TugrikCircle({ bg, fg }: { bg: string; fg: string }) {
return (
<div
style={{
width: 49,
height: 49,
borderRadius: "50%",
background: bg,
color: fg,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontWeight: 700,
fontSize: 20,
flexShrink: 0,
}}
>
</div>
);
}
/** 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 (
<div style={{ display: "flex", flexDirection: "column", gap: 18 }}>
<header style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<span style={{ fontWeight: 600, fontSize: 18, color: "var(--seed-color-fg-neutral)" }}>{s.wordmark}</span>
<HideAmountsToggle label={s.hideAmounts} />
</header>
{/* Safe-to-spend hero (green): how much is left to spend this month
after committed obligations and what's already been spent. */}
<Link
href="/accounting"
style={{
display: "block",
textDecoration: "none",
color: "var(--mercury-on-brand)",
background: "var(--mercury-balance-card)",
borderRadius: "var(--seed-radius-r5, 20px)",
padding: "14px 20px",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<TugrikCircle bg="var(--mercury-balance-circle)" fg="var(--mercury-on-brand)" />
<div style={{ display: "flex", flexDirection: "column" }}>
<span style={{ fontSize: 12 }}>{data.overspent ? s.overspent : s.safeToSpend}</span>
<span style={{ fontSize: 26, fontWeight: 700 }}>
<Amount value={data.safeToSpend} loading={loading} width="140px" />
</span>
</div>
</div>
<div
style={{
marginTop: 12,
height: 6,
borderRadius: 999,
background: "rgba(0,0,0,0.13)",
overflow: "hidden",
}}
>
<div
style={{
height: "100%",
width: `${Math.max(2, fraction * 100)}%`,
borderRadius: 999,
background: data.overspent ? "var(--seed-color-bg-critical, #d92d20)" : "var(--mercury-on-brand)",
}}
/>
</div>
<div style={{ marginTop: 8, display: "flex", justifyContent: "space-between", fontSize: 12 }}>
<span>{s.spent}</span>
<span style={{ fontWeight: 700, fontSize: 13 }}>
<Amount value={data.monthlyExpense} loading={loading} />
<span style={{ fontWeight: 400, fontSize: 11 }}> / {tugrikShortRaw(budgetDenominator)}</span>
</span>
</div>
</Link>
{/* Daily limit + top budgets (blue). */}
<Link
href="/planner"
style={{
display: "block",
textDecoration: "none",
color: "#fff",
background: "var(--mercury-limit-card)",
borderRadius: "var(--seed-radius-r5, 20px)",
padding: "14px 20px",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<ProgressCircleRoot value={dailyFraction * 100} size="40" tone="staticWhite">
<ProgressCircleTrack />
<ProgressCircleRange />
</ProgressCircleRoot>
<div style={{ display: "flex", flexDirection: "column" }}>
<span style={{ fontSize: 12 }}>{s.dailyLimit}</span>
<span style={{ fontSize: 22, fontWeight: 700 }}>
<Amount value={data.dailyLimitUsed} loading={loading} width="60px" />
{" / "}
<Amount value={data.dailyLimitTotal} loading={loading} width="60px" />
</span>
</div>
</div>
<div style={{ marginTop: 4, fontSize: 11, opacity: 0.85 }}>
{s.todaySpent}: <Amount value={data.todayExpense} loading={loading} width="50px" />
</div>
<hr style={{ margin: "12px 0", border: 0, borderTop: "1px solid rgba(0,0,0,0.13)" }} />
{data.budgets.map((b) => (
<div key={b.name} style={{ display: "flex", justifyContent: "space-between", padding: "4px 0" }}>
<span style={{ fontSize: 12 }}>{b.name}</span>
<span style={{ fontSize: 14, fontWeight: 700 }}>
<span style={{ color: "var(--mercury-warning-chip)" }}>
<Amount value={b.spent} loading={loading} width="50px" />
</span>
<span style={{ fontWeight: 700, fontSize: 11 }}> / {b.range}</span>
</span>
</div>
))}
</Link>
{/* This month's spend (white). */}
<Card style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<span style={{ fontWeight: 700, fontSize: 14, color: "var(--seed-color-fg-placeholder)" }}>{s.thisMonth}</span>
<div style={{ display: "flex", alignItems: "baseline", gap: 4 }}>
{loading ? (
<Skeleton height="1.6em" width="120px" />
) : (
<span style={{ fontSize: 26, fontWeight: 700, color: "var(--seed-color-fg-neutral)" }}>
{tugrikShortRaw(data.monthlyExpense)}
</span>
)}
<span style={{ fontSize: 16, fontWeight: 600, color: "var(--seed-color-fg-placeholder)" }}>
{s.expenseLabel}
</span>
</div>
<div style={{ display: "flex", justifyContent: "flex-end" }}>
<Link
href="/accounting"
style={{
fontSize: 12,
color: "var(--mercury-on-brand)",
background: "var(--mercury-warning-chip)",
borderRadius: 999,
padding: "8px 12px",
textDecoration: "none",
}}
>
{s.viewAll}
</Link>
</div>
</Card>
{/* Recent income / expense ledgers. */}
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<Card>
<span style={{ fontWeight: 700, fontSize: 14, color: "var(--seed-color-fg-placeholder)" }}>{s.income}</span>
{data.income.map((row, i) => (
<div key={i} style={{ display: "flex", justifyContent: "space-between", padding: "6px 0" }}>
<span style={{ color: "var(--seed-color-fg-neutral)" }}>{row.title}</span>
<span style={{ color: "var(--seed-color-fg-placeholder)" }}>{row.date}</span>
</div>
))}
</Card>
<Card>
<span style={{ fontWeight: 700, fontSize: 14, color: "var(--seed-color-fg-placeholder)" }}>{s.expense}</span>
{data.expense.map((row, i) => (
<div key={i} style={{ display: "flex", justifyContent: "space-between", padding: "6px 0" }}>
<span style={{ color: "var(--seed-color-fg-neutral)" }}>{row.title}</span>
<span style={{ color: "var(--seed-color-fg-placeholder)" }}>{row.date}</span>
</div>
))}
</Card>
</div>
</div>
);
}

View file

@ -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);
});
});

View file

@ -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,
};
}

View file

@ -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,
};

View file

@ -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;