feat(web): money-clarity home — cash-flow hero + where-it-went + recurring + net worth + categorize nudge

This commit is contained in:
Munkherdene 2026-08-23 00:04:20 +08:00
parent 7014ed3d2e
commit 3f42f21504
17 changed files with 853 additions and 212 deletions

View file

@ -39,6 +39,11 @@ export const useAnalyzeToday = () => {
}); });
}; };
/** All-time analyze (adds the `months` trend series) powers the home
* dashboard's 6-month cash-flow mini chart. */
export const useAnalyzeAll = () =>
useQuery({ queryKey: keys.analyzeAll, queryFn: () => apiGet("/analyze?period=all", AnalyzeSchema) });
export const useBudget = () => useQuery({ queryKey: keys.budget, queryFn: () => apiGet("/budget", BudgetSchema) }); export const useBudget = () => useQuery({ queryKey: keys.budget, queryFn: () => apiGet("/budget", BudgetSchema) });
export interface TransactionsParams { export interface TransactionsParams {

View file

@ -2,6 +2,7 @@ export const keys = {
networth: ["networth"] as const, networth: ["networth"] as const,
analyzeMonth: ["analyze", "month"] as const, analyzeMonth: ["analyze", "month"] as const,
analyzeToday: (d: string) => ["analyze", "today", d] as const, analyzeToday: (d: string) => ["analyze", "today", d] as const,
analyzeAll: ["analyze", "all"] as const,
budget: ["budget"] as const, budget: ["budget"] as const,
transactions: (p: string) => ["transactions", p] as const, transactions: (p: string) => ["transactions", p] as const,
categories: ["categories"] as const, categories: ["categories"] as const,

View file

@ -2,13 +2,40 @@
import * as React from "react"; import * as React from "react";
import Link from "next/link"; import Link from "next/link";
import { Skeleton, ProgressCircleRoot, ProgressCircleTrack, ProgressCircleRange } from "@seed-design/react"; import { Skeleton } from "@seed-design/react";
import { Card, AmountToggle, HideAmountsToggle, IconChip, SectionHeader, EmptyState, SyncButton, categoryStyle } from "../../ds"; import {
import { tugrikShortRaw } from "../../ds/money"; Card,
import { useNetWorth, useAnalyzeMonth, useAnalyzeToday, useBudget } from "../../api/hooks/reads"; IconChip,
import { buildHome, type HomeData, type LedgerRow as LedgerRowData } from "./buildHome"; SectionHeader,
EmptyState,
Icon,
HideAmountsToggle,
SyncButton,
categoryStyle,
} from "../../ds";
import type { IconName } from "../../ds/icons";
import { AmountToggle } from "../../ds/AmountToggle";
import { tugrik, tugrikShort, tugrikShortRaw } from "../../ds/money";
import {
useNetWorth,
useAnalyzeMonth,
useAnalyzeAll,
useBudget,
useSubscriptions,
useTransactions,
} from "../../api/hooks/reads";
import type { Named } from "../../api/schemas/analyze";
import type { Subscription } from "../../api/schemas/subscription";
import { buildHome, dec, type HomeData } from "./buildHome";
import { buildTrendMonths, buildCashFlowVerdict, type TrendMonth } from "./cashFlowTrend";
import { topExpenseCategories, topExpensePayees, shareOf } from "./whereItWent";
import { recurringMonthlyTotal, detectRecurringMerchants } from "./recurring";
import { buildNetWorthComposition } from "./netWorthComposition";
import { countUncategorized } from "./categorizeNudge";
import { sample } from "./sample"; import { sample } from "./sample";
import { homeStrings as s } from "./strings"; import { homeStrings as s } from "./strings";
import { monthRange } from "../accounting/monthRange";
import { useHiddenAmounts } from "../accounting/useHiddenAmounts";
/** A number that's redacted with a Seed skeleton block while loading, and a /** 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 * tap-to-reveal `AmountToggle` once real data has arrived. Mirrors iOS's
@ -42,87 +69,187 @@ function TugrikCircle({ bg, fg }: { bg: string; fg: string }) {
); );
} }
/** One Орлого/Зарлага row: an IconChip (income the wallet/green "income" /** Compact inline-SVG 6-month in-vs-out chart for the cash-flow hero. No
* style; expense the payee's category style, falling back to a neutral * chart deps two bars per month (income / expense), a muted baseline, and
* receipt when unknown) + the payee name + a trailing colored amount. * short month labels. Sits on the hero's fixed light-green brand card, so
* Mirrors TransactionList's TxnRow pattern. */ * colors are fixed hex (not theme tokens) to stay legible in both themes. */
function LedgerEntryRow({ row, income }: { row: LedgerRowData; income: boolean }) { function CashFlowMiniChart({ months }: { months: TrendMonth[] }) {
const cat = categoryStyle(income ? undefined : row.date, income); const max = Math.max(1, ...months.flatMap((m) => [m.income, m.expense]));
const W = 300;
const H = 78;
const base = H - 14;
const top = 6;
const groupW = W / months.length;
const barW = Math.min(16, groupW / 3.2);
return ( return (
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, padding: "8px 0" }}> <div>
<div style={{ display: "flex", alignItems: "center", gap: 12, minWidth: 0 }}> <div style={{ display: "flex", gap: 14, fontSize: 10, opacity: 0.75, marginBottom: 4 }}>
<IconChip icon={cat.icon} tint={cat.tint} fg={cat.fg} /> <span style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
<i aria-hidden style={{ width: 7, height: 7, borderRadius: 2, background: "#1B8F60", display: "inline-block" }} />
{s.income}
</span>
<span style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
<i aria-hidden style={{ width: 7, height: 7, borderRadius: 2, background: "#A83232", display: "inline-block" }} />
{s.expense}
</span>
</div>
<svg viewBox={`0 0 ${W} ${H}`} width="100%" height={H} preserveAspectRatio="none" role="img" aria-label={s.trendAria}>
<line x1={0} y1={base} x2={W} y2={base} stroke="rgba(0,0,0,0.15)" strokeWidth={1} />
{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 (
<g key={m.month}>
<rect x={cx - barW - 2} y={base - Math.max(1, incomeH)} width={barW} height={Math.max(1, incomeH)} rx={2} fill="#1B8F60" />
<rect
x={cx + 2}
y={base - Math.max(1, expenseH)}
width={barW}
height={Math.max(1, expenseH)}
rx={2}
fill="#A83232"
opacity={0.9}
/>
<text x={cx} y={H - 2} textAnchor="middle" fontSize={8} fill="var(--mercury-on-brand)" opacity={0.6}>
{m.label}
</text>
</g>
);
})}
</svg>
</div>
);
}
/** Thin proportional bar behind a category/merchant row's amount. */
function ShareBar({ pct, color }: { pct: number; color: string }) {
return (
<div style={{ height: 5, borderRadius: 999, background: "var(--seed-color-bg-neutral-subtle, #eef0f2)", overflow: "hidden" }}>
<div style={{ height: "100%", width: `${Math.max(3, pct)}%`, borderRadius: 999, background: color }} />
</div>
);
}
function CategoryShareRow({ item, items }: { item: Named; items: Named[] }) {
const style = categoryStyle(item.name);
const pct = shareOf(item, items) * 100;
return (
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<IconChip icon={style.icon} tint={style.tint} fg={style.fg} size={36} />
<div style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", gap: 4 }}>
<div style={{ display: "flex", justifyContent: "space-between", gap: 8 }}>
<span style={{ fontSize: 14, fontWeight: 700, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{style.name}
</span>
<span style={{ fontSize: 14, fontWeight: 700, flexShrink: 0 }}>{tugrik(item.total)}</span>
</div>
<ShareBar pct={pct} color={style.fg} />
</div>
</div>
);
}
function MerchantShareRow({ item, items }: { item: Named; items: Named[] }) {
const pct = shareOf(item, items) * 100;
return (
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<div style={{ display: "flex", justifyContent: "space-between", gap: 8 }}>
<span <span
style={{ style={{
fontSize: 15, fontSize: 14,
fontWeight: 700, fontWeight: 600,
color: "var(--seed-color-fg-neutral)",
overflow: "hidden", overflow: "hidden",
textOverflow: "ellipsis", textOverflow: "ellipsis",
whiteSpace: "nowrap", whiteSpace: "nowrap",
color: "var(--seed-color-fg-neutral)",
}} }}
> >
{row.date} {item.name}
</span> </span>
<span style={{ fontSize: 14, fontWeight: 700, flexShrink: 0 }}>{tugrik(item.total)}</span>
</div> </div>
<span <ShareBar pct={pct} color="var(--seed-color-fg-neutral-muted, #8b8b8b)" />
style={{
fontSize: 15,
fontWeight: 700,
flexShrink: 0,
color: income ? "var(--mercury-success, #1e9e6b)" : "var(--mercury-critical, #e5484d)",
}}
>
{income ? "+" : ""}
{row.title}
</span>
</div> </div>
); );
} }
/** Skeleton placeholder for a ledger row, shaped like `LedgerEntryRow` so the /** One subscription/bill row on the recurring card. */
* layout doesn't jump once real data (or the sample fallback) arrives function RecurringRow({ sub, icon }: { sub: Subscription; icon: IconName }) {
* avoids flashing the sample payee/amount as if it were a real loaded row. */
function LedgerRowSkeleton() {
return ( return (
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "8px 0" }}> <div style={{ display: "flex", alignItems: "center", gap: 12, padding: "8px 0" }}>
<Skeleton height="40px" width="40px" style={{ borderRadius: 13, flexShrink: 0 }} /> <IconChip icon={icon} tint="var(--seed-color-bg-neutral-subtle, #eef0f2)" fg="var(--seed-color-fg-neutral)" size={36} />
<Skeleton height="1em" width="55%" /> <div style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column" }}>
<span style={{ fontSize: 14, fontWeight: 700, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{sub.label}
</span>
<span style={{ fontSize: 12, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}>{sub.cadence}</span>
</div>
<span style={{ fontSize: 14, fontWeight: 700, flexShrink: 0 }}>{tugrik(sub.monthly)}</span>
</div> </div>
); );
} }
/** The Нүүр (home) dashboard: header, safe-to-spend hero, daily-limit + function Dot({ color }: { color: string }) {
* top budgets, this-month spend, and recent income/expense. Figures come return <span aria-hidden style={{ display: "inline-block", width: 7, height: 7, borderRadius: 2, background: color, marginRight: 5 }} />;
* from `buildHome` (real where the backend has them, `sample` otherwise). }
* Ported from ios/Mercury/Features/Home/DashboardView.swift. */
/** 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() { export function DashboardView() {
const netWorthQ = useNetWorth(); const netWorthQ = useNetWorth();
const monthQ = useAnalyzeMonth(); const monthQ = useAnalyzeMonth();
const todayQ = useAnalyzeToday();
const budgetQ = useBudget(); 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({ const heroLoading = netWorthQ.isLoading || monthQ.isLoading || budgetQ.isLoading;
netWorth: netWorthQ.data,
month: monthQ.data, const built = buildHome({ netWorth: netWorthQ.data, month: monthQ.data, today: undefined, budget: budgetQ.data });
today: todayQ.data,
budget: budgetQ.data,
});
const data: HomeData = built ?? sample; const data: HomeData = built ?? sample;
const hasRealMonth = built !== null;
const budgetDenominator = data.availableBudget; const budgetDenominator = data.availableBudget;
const fraction = budgetDenominator > 0 ? Math.min(1, data.monthlyExpense / budgetDenominator) : 0; 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 const trendMonths = buildTrendMonths(trendQ.data?.months);
// this isn't the full pre-connection sample), but no discretionary spend const verdict = buildCashFlowVerdict(dec(monthQ.data?.income), dec(monthQ.data?.expense));
// 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 monthTxns = monthTxnsQ.data ?? [];
const noRealPayees = const uncategorizedCount = countUncategorized(monthTxns);
(monthQ.data?.incomePayees?.length ?? 0) === 0 && (monthQ.data?.expensePayees?.length ?? 0) === 0;
const sparseMonth = built !== null && data.monthlyExpense === 0 && noRealPayees; 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 ( return (
<div style={{ display: "flex", flexDirection: "column", gap: 18 }}> <div style={{ display: "flex", flexDirection: "column", gap: 18 }}>
@ -134,165 +261,275 @@ export function DashboardView() {
</div> </div>
</header> </header>
<div className="grid grid-cols-1 gap-[18px] md:grid-cols-2 md:items-start md:gap-5"> {/* 5. Categorize nudge the memo's keystone: made visible at the top,
{/* Safe-to-spend hero (green): how much is left to spend this month not tucked in as the last card. A slim banner, not a full module. */}
after committed obligations and what's already been spent. */} {!monthTxnsQ.isLoading && uncategorizedCount > 0 && (
<Link <Link
href="/accounting" href="/accounting/review"
style={{
display: "block",
textDecoration: "none",
color: "var(--mercury-on-brand)",
background: "var(--mercury-balance-card)",
borderRadius: "var(--seed-radius-r5, 20px)",
padding: "18px 20px",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
<TugrikCircle bg="var(--mercury-balance-circle)" fg="var(--mercury-on-brand)" />
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
<span style={{ fontSize: 12, opacity: 0.85 }}>{data.overspent ? s.overspent : s.safeToSpend}</span>
<span style={{ fontSize: 27, fontWeight: 700, lineHeight: 1.15 }}>
<Amount value={data.safeToSpend} loading={loading} width="140px" />
</span>
</div>
</div>
<div
style={{ style={{
marginTop: 14, display: "flex",
height: 6, alignItems: "center",
borderRadius: 999, justifyContent: "space-between",
background: "rgba(0,0,0,0.13)", gap: 12,
overflow: "hidden", textDecoration: "none",
color: "var(--mercury-on-brand)",
background: "var(--mercury-warning-chip)",
borderRadius: "var(--seed-radius-r3)",
padding: "10px 14px",
}} }}
> >
<div <div style={{ display: "flex", alignItems: "center", gap: 10, minWidth: 0 }}>
style={{ <IconChip icon="list" tint="rgba(0,0,0,0.08)" fg="var(--mercury-on-brand)" size={34} />
height: "100%", <span style={{ fontSize: 14, fontWeight: 700 }}>{s.nudge(uncategorizedCount)}</span>
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: 10, display: "flex", justifyContent: "space-between", alignItems: "baseline", fontSize: 12 }}>
<span style={{ opacity: 0.85 }}>{s.spent}</span>
<span style={{ fontWeight: 700, fontSize: 13 }}>
<Amount value={data.monthlyExpense} loading={loading} />
<span style={{ fontWeight: 400, fontSize: 11, opacity: 0.85 }}> / {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: "18px 20px",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
<ProgressCircleRoot value={dailyFraction * 100} size="40" tone="staticWhite">
<ProgressCircleTrack />
<ProgressCircleRange />
</ProgressCircleRoot>
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
<span style={{ fontSize: 12, opacity: 0.85 }}>{s.dailyLimit}</span>
<span style={{ fontSize: 22, fontWeight: 700, lineHeight: 1.15 }}>
<Amount value={data.dailyLimitUsed} loading={loading} width="60px" />
{" / "}
<Amount value={data.dailyLimitTotal} loading={loading} width="60px" />
</span>
</div> </div>
</div> <span
<div style={{ marginTop: 6, fontSize: 11, opacity: 0.85 }}>
{s.todaySpent}: <Amount value={data.todayExpense} loading={loading} width="50px" />
</div>
<hr style={{ margin: "14px 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", alignItems: "baseline", padding: "6px 0" }}>
<span style={{ fontSize: 12, opacity: 0.85 }}>{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={{ style={{
fontSize: 12, fontSize: 13,
color: "var(--mercury-on-brand)", fontWeight: 700,
background: "var(--mercury-warning-chip)", flexShrink: 0,
padding: "7px 14px",
borderRadius: 999, borderRadius: 999,
padding: "8px 12px", background: "var(--mercury-on-brand)",
textDecoration: "none", color: "var(--mercury-brand-yellow)",
}} }}
> >
{s.viewAll} {s.nudgeCta}
</Link> </span>
</div> </Link>
</Card> )}
{/* Recent income / expense ledgers. */} <div className="grid grid-cols-1 gap-[18px] md:grid-cols-2 md:items-start md:gap-5">
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}> {/* 1. Safe-to-spend + cash-flow hero. */}
{loading ? ( <Link
<> href="/accounting"
<Card style={{ display: "flex", flexDirection: "column", gap: 8 }}> style={{
<SectionHeader title={s.income} /> display: "block",
<LedgerRowSkeleton /> textDecoration: "none",
</Card> color: "var(--mercury-on-brand)",
<Card style={{ display: "flex", flexDirection: "column", gap: 8 }}> background: "var(--mercury-balance-card)",
<SectionHeader title={s.expense} /> borderRadius: "var(--seed-radius-r5, 20px)",
<LedgerRowSkeleton /> padding: "18px 20px",
</Card> }}
</> >
) : sparseMonth ? ( <div style={{ display: "flex", alignItems: "center", gap: 14 }}>
<Card> <TugrikCircle bg="var(--mercury-balance-circle)" fg="var(--mercury-on-brand)" />
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
<span style={{ fontSize: 12, opacity: 0.85 }}>{data.overspent ? s.overspent : s.safeToSpend}</span>
<span style={{ fontSize: 27, fontWeight: 700, lineHeight: 1.15 }}>
<Amount value={data.safeToSpend} loading={heroLoading} width="140px" />
</span>
</div>
</div>
<div style={{ marginTop: 14, 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: 10, display: "flex", justifyContent: "space-between", alignItems: "baseline", fontSize: 12 }}>
<span style={{ opacity: 0.85 }}>{s.spent}</span>
<span style={{ fontWeight: 700, fontSize: 13 }}>
<Amount value={data.monthlyExpense} loading={heroLoading} />
<span style={{ fontWeight: 400, fontSize: 11, opacity: 0.85 }}> / {tugrikShortRaw(budgetDenominator)}</span>
</span>
</div>
{hasRealMonth && (
<>
<hr style={{ margin: "16px 0 12px", border: 0, borderTop: "1px solid rgba(0,0,0,0.13)" }} />
{trendMonths.length > 0 && <CashFlowMiniChart months={trendMonths} />}
<div style={{ marginTop: trendMonths.length > 0 ? 10 : 0, fontSize: 13, fontWeight: 700 }}>
<span style={{ color: verdict.positive ? "#1B8F60" : "#A83232" }}>
{verdict.positive ? s.verdictPositive : s.verdictNegative(tugrikShort(verdict.diff))}
</span>
</div>
</>
)}
</Link>
{/* 2. Хаана зарцуулсан бэ? — top categories + top merchants. */}
<Card style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<SectionHeader title={s.whereWentTitle} />
{whereItWentLoading ? (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<Skeleton height="1em" width="70%" />
<Skeleton height="1em" width="55%" />
<Skeleton height="1em" width="60%" />
</div>
) : !hasWhereItWent ? (
<EmptyState icon="receipt" title={s.noSpendTitle} hint={s.noSpendHint} compact /> <EmptyState icon="receipt" title={s.noSpendTitle} hint={s.noSpendHint} compact />
</Card> ) : (
) : ( <>
<> {topCategories.length > 0 && (
<Card style={{ display: "flex", flexDirection: "column", gap: 8 }}> <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<SectionHeader title={s.income} /> <span
{data.income.map((row, i) => ( style={{
<LedgerEntryRow key={i} row={row} income /> fontSize: 12,
fontWeight: 700,
color: "var(--seed-color-fg-neutral-muted, #8b8b8b)",
textTransform: "uppercase",
letterSpacing: 0.4,
}}
>
{s.categoriesLabel}
</span>
{topCategories.map((c) => (
<CategoryShareRow key={c.name || "__uncategorized"} item={c} items={topCategories} />
))}
</div>
)}
{topMerchants.length > 0 && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<span
style={{
fontSize: 12,
fontWeight: 700,
color: "var(--seed-color-fg-neutral-muted, #8b8b8b)",
textTransform: "uppercase",
letterSpacing: 0.4,
}}
>
{s.merchantsLabel}
</span>
{topMerchants.map((p) => (
<MerchantShareRow key={p.name} item={p} items={topMerchants} />
))}
</div>
)}
</>
)}
</Card>
{/* 3. Тогтмол төлбөр — recurring & subscriptions. */}
<Card style={{ display: "flex", flexDirection: "column", gap: 14 }}>
<SectionHeader title={s.recurringTitle} />
{recurringLoading ? (
<Skeleton height="1.4em" width="140px" />
) : (
<div style={{ display: "flex", alignItems: "baseline", gap: 6 }}>
<span style={{ fontSize: 22, fontWeight: 700 }}>{tugrik(recurringTotal)}</span>
<span style={{ fontSize: 13, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}>{s.perMonth}</span>
</div>
)}
{!recurringLoading && subs.length === 0 && bills.length === 0 ? (
<EmptyState icon="sparkles" title={s.noSubsTitle} hint={s.noSubsHint} compact />
) : (
!recurringLoading && (
<div style={{ display: "flex", flexDirection: "column" }}>
{subs.map((sub) => (
<RecurringRow key={sub.matchKey ?? sub.label} sub={sub} icon="sparkles" />
))}
{bills.map((bill) => (
<RecurringRow key={bill.matchKey ?? bill.label} sub={bill} icon="card" />
))}
</div>
)
)}
{!recurringLoading && detected.length > 0 && (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<span
style={{
fontSize: 12,
fontWeight: 700,
color: "var(--seed-color-fg-neutral-muted, #8b8b8b)",
textTransform: "uppercase",
letterSpacing: 0.4,
}}
>
{s.detectedRecurringLabel}
</span>
{detected.slice(0, 3).map((d) => (
<div key={d.key} style={{ display: "flex", alignItems: "center", gap: 12, padding: "6px 0" }}>
<IconChip
icon="repeat"
tint="var(--seed-color-bg-neutral-subtle, #eef0f2)"
fg="var(--seed-color-fg-neutral-muted, #8b8b8b)"
size={34}
/>
<div style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column" }}>
<span
style={{ fontSize: 13, fontWeight: 700, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
>
{d.label}
</span>
<span style={{ fontSize: 11, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}>
{s.detectedTimes(d.count)}
</span>
</div>
<span style={{ fontSize: 13, fontWeight: 700, flexShrink: 0 }}>{tugrik(d.total)}</span>
</div>
))} ))}
</Card> </div>
<Card style={{ display: "flex", flexDirection: "column", gap: 8 }}> )}
<SectionHeader title={s.expense} /> </Card>
{data.expense.map((row, i) => (
<LedgerEntryRow key={i} row={row} income={false} /> {/* 4. Цэвэр хөрөнгө — net worth + composition. */}
))} <Card style={{ display: "flex", flexDirection: "column", gap: 14 }}>
</Card> <SectionHeader title={s.netWorthTitle} />
</> {netWorthLoading ? (
)} <Skeleton height="1.8em" width="160px" />
</div> ) : (
<span style={{ fontSize: 26, fontWeight: 700, color: "var(--seed-color-fg-neutral)" }}>{tugrik(composition.total)}</span>
)}
{!netWorthLoading && compTotal > 0 && (
<>
<div
style={{
height: 8,
borderRadius: 999,
overflow: "hidden",
display: "flex",
background: "var(--seed-color-bg-neutral-subtle, #eef0f2)",
}}
>
<div style={{ width: `${bankPct}%`, background: "#215C9A" }} />
<div style={{ width: `${100 - bankPct}%`, background: "#9A2E77" }} />
</div>
<div
style={{
display: "flex",
flexWrap: "wrap",
justifyContent: "space-between",
gap: 8,
fontSize: 12,
color: "var(--seed-color-fg-neutral-muted, #8b8b8b)",
}}
>
<span>
<Dot color="#215C9A" />
{s.bankAccountsLabel} · {tugrik(composition.bankTotal)}
</span>
<span>
<Dot color="#9A2E77" />
{s.manualAssetsLabel} · {tugrik(composition.manualTotal)}
</span>
</div>
</>
)}
{!netWorthLoading && composition.liabilities > 0 && (
<div style={{ fontSize: 12, color: "var(--seed-color-fg-critical)" }}>
{s.liabilitiesLabel}: {tugrik(composition.liabilities)}
</div>
)}
<Link
href="/assets"
style={{
fontSize: 13,
fontWeight: 700,
color: "var(--seed-color-fg-neutral)",
textDecoration: "none",
display: "inline-flex",
alignItems: "center",
gap: 4,
}}
>
{s.viewAssets} <Icon name="chevron-right" size={14} />
</Link>
</Card>
</div> </div>
</div> </div>
); );

View file

@ -7,7 +7,7 @@ import { sample } from "./sample";
/** Parse a backend decimal string ("900000") into a number. Mirrors /** Parse a backend decimal string ("900000") into a number. Mirrors
* `Decimal(string:) ?? 0` in HomeData.swift's local `dec` helper. */ * `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; if (!s) return 0;
const n = Number(s); const n = Number(s);
return Number.isFinite(n) ? n : 0; return Number.isFinite(n) ? n : 0;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<string>,
minCount = 3,
): DetectedRecurring[] {
const groups = new Map<string, { label: string; count: number; total: number }>();
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);
}

View file

@ -1,18 +1,42 @@
// Home dashboard copy, ported verbatim from // Home dashboard copy. The "money clarity" rebuild (mercury-rethink memo):
// ios/Mercury/Features/Home/DashboardView.swift. // cash-flow hero, where-it-went, recurring, net worth, categorize nudge.
export const homeStrings = { export const homeStrings = {
wordmark: "MERCURY", wordmark: "MERCURY",
hideAmounts: "Мөнгөн дүн нуух",
// 1. Safe-to-spend + cash-flow hero.
safeToSpend: "Энэ сар зарцуулж болох", safeToSpend: "Энэ сар зарцуулж болох",
overspent: "Төсвөөс хэтэрсэн", overspent: "Төсвөөс хэтэрсэн",
spent: "Зарцуулсан", spent: "Зарцуулсан",
dailyLimit: "Өнөөдрийн лимит",
thisMonth: "Энэ сард",
expenseLabel: "зарлага",
viewAll: "Бүгдийг харах",
hideAmounts: "Мөнгөн дүн нуух",
income: "Орлого", income: "Орлого",
expense: "Зарлага", expense: "Зарлага",
todaySpent: "Өнөөдөр зарцуулсан", verdictPositive: "Энэ сар орлого зарлагаа даасан",
verdictNegative: (amount: string) => `Энэ сар ${amount}-р илүү зарцуулсан`,
trendAria: "Сүүлийн 6 сарын орлого, зарлагын харьцуулалт",
// Categorize nudge.
nudge: (count: number) => `${count} гүйлгээ ангилаагүй байна`,
nudgeCta: "Ангилах",
// 2. Хаана зарцуулсан бэ?
whereWentTitle: "Хаана зарцуулсан бэ?",
categoriesLabel: "Ангилал",
merchantsLabel: "Худалдагч",
noSpendTitle: "Энэ сар хараахан зарлага алга", noSpendTitle: "Энэ сар хараахан зарлага алга",
noSpendHint: "Гүйлгээ хийгдмэгц энд харагдана.", noSpendHint: "Гүйлгээ хийгдмэгц энд харагдана.",
// 3. Тогтмол төлбөр.
recurringTitle: "Тогтмол төлбөр",
perMonth: "/ сар",
detectedRecurringLabel: "Илэрсэн тогтмол",
detectedTimes: (count: number) => `Энэ сард ${count} удаа`,
noSubsTitle: "Тогтмол төлбөр алга",
noSubsHint: "Захиалга, дансны төлбөрүүд энд харагдана.",
// 4. Цэвэр хөрөнгө.
netWorthTitle: "Цэвэр хөрөнгө",
bankAccountsLabel: "Банкны данс",
manualAssetsLabel: "Бусад хөрөнгө",
liabilitiesLabel: "Өр төлбөр",
viewAssets: "Хөрөнгийн жагсаалт",
} as const; } as const;

View file

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

View file

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

View file

@ -21,6 +21,18 @@ export const analyzeToday = { from: "2026-08-22", to: "2026-08-22", income: "0",
expenseCategories: [{ name: "Хоол", count: 1, total: "52000" }], expenseCategories: [{ name: "Хоол", count: 1, total: "52000" }],
incomePayees: [], expensePayees: [{ 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 category = { name: "Хоол", kind: "expense", depth: 0, icon: null };
export const categories = [category]; export const categories = [category];

View file

@ -16,6 +16,7 @@ export const handlers = [
// vs useAnalyzeToday in src/api/hooks/reads.ts. // vs useAnalyzeToday in src/api/hooks/reads.ts.
http.get("/api/v1/analyze", ({ request }) => { http.get("/api/v1/analyze", ({ request }) => {
const url = new URL(request.url); 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"); const isToday = url.searchParams.has("from") || url.searchParams.has("to");
return HttpResponse.json(isToday ? fixtures.analyzeToday : fixtures.analyzeMonth); return HttpResponse.json(isToday ? fixtures.analyzeToday : fixtures.analyzeMonth);
}), }),