feat(web): home dashboard + data assembly ported from iOS
This commit is contained in:
parent
2555e42f21
commit
762b572d30
6 changed files with 491 additions and 3 deletions
|
|
@ -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() {
|
export default function HomePage() {
|
||||||
return <h1 style={{ fontWeight: 700 }}>{t.tabs.home}</h1>;
|
return <DashboardView />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
223
src/features/home/DashboardView.tsx
Normal file
223
src/features/home/DashboardView.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
97
src/features/home/buildHome.test.ts
Normal file
97
src/features/home/buildHome.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
128
src/features/home/buildHome.ts
Normal file
128
src/features/home/buildHome.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
25
src/features/home/sample.ts
Normal file
25
src/features/home/sample.ts
Normal 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,
|
||||||
|
};
|
||||||
16
src/features/home/strings.ts
Normal file
16
src/features/home/strings.ts
Normal 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;
|
||||||
Loading…
Add table
Reference in a new issue