feat(web): money-clarity home — cash-flow hero + where-it-went + recurring + net worth + categorize nudge
This commit is contained in:
parent
7014ed3d2e
commit
3f42f21504
17 changed files with 853 additions and 212 deletions
|
|
@ -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 interface TransactionsParams {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ export const keys = {
|
|||
networth: ["networth"] as const,
|
||||
analyzeMonth: ["analyze", "month"] as const,
|
||||
analyzeToday: (d: string) => ["analyze", "today", d] as const,
|
||||
analyzeAll: ["analyze", "all"] as const,
|
||||
budget: ["budget"] as const,
|
||||
transactions: (p: string) => ["transactions", p] as const,
|
||||
categories: ["categories"] as const,
|
||||
|
|
|
|||
|
|
@ -2,13 +2,40 @@
|
|||
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import { Skeleton, ProgressCircleRoot, ProgressCircleTrack, ProgressCircleRange } from "@seed-design/react";
|
||||
import { Card, AmountToggle, HideAmountsToggle, IconChip, SectionHeader, EmptyState, SyncButton, categoryStyle } from "../../ds";
|
||||
import { tugrikShortRaw } from "../../ds/money";
|
||||
import { useNetWorth, useAnalyzeMonth, useAnalyzeToday, useBudget } from "../../api/hooks/reads";
|
||||
import { buildHome, type HomeData, type LedgerRow as LedgerRowData } from "./buildHome";
|
||||
import { Skeleton } from "@seed-design/react";
|
||||
import {
|
||||
Card,
|
||||
IconChip,
|
||||
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 { 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
|
||||
* 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"
|
||||
* style; expense → the payee's category style, falling back to a neutral
|
||||
* receipt when unknown) + the payee name + a trailing colored amount.
|
||||
* Mirrors TransactionList's TxnRow pattern. */
|
||||
function LedgerEntryRow({ row, income }: { row: LedgerRowData; income: boolean }) {
|
||||
const cat = categoryStyle(income ? undefined : row.date, income);
|
||||
/** Compact inline-SVG 6-month in-vs-out chart for the cash-flow hero. No
|
||||
* chart deps — two bars per month (income / expense), a muted baseline, and
|
||||
* short month labels. Sits on the hero's fixed light-green brand card, so
|
||||
* colors are fixed hex (not theme tokens) to stay legible in both themes. */
|
||||
function CashFlowMiniChart({ months }: { months: TrendMonth[] }) {
|
||||
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 (
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, padding: "8px 0" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, minWidth: 0 }}>
|
||||
<IconChip icon={cat.icon} tint={cat.tint} fg={cat.fg} />
|
||||
<div>
|
||||
<div style={{ display: "flex", gap: 14, fontSize: 10, opacity: 0.75, marginBottom: 4 }}>
|
||||
<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
|
||||
style={{
|
||||
fontSize: 15,
|
||||
fontWeight: 700,
|
||||
color: "var(--seed-color-fg-neutral)",
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
color: "var(--seed-color-fg-neutral)",
|
||||
}}
|
||||
>
|
||||
{row.date}
|
||||
{item.name}
|
||||
</span>
|
||||
<span style={{ fontSize: 14, fontWeight: 700, flexShrink: 0 }}>{tugrik(item.total)}</span>
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 15,
|
||||
fontWeight: 700,
|
||||
flexShrink: 0,
|
||||
color: income ? "var(--mercury-success, #1e9e6b)" : "var(--mercury-critical, #e5484d)",
|
||||
}}
|
||||
>
|
||||
{income ? "+" : "−"}
|
||||
{row.title}
|
||||
</span>
|
||||
<ShareBar pct={pct} color="var(--seed-color-fg-neutral-muted, #8b8b8b)" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Skeleton placeholder for a ledger row, shaped like `LedgerEntryRow` so the
|
||||
* layout doesn't jump once real data (or the sample fallback) arrives —
|
||||
* avoids flashing the sample payee/amount as if it were a real loaded row. */
|
||||
function LedgerRowSkeleton() {
|
||||
/** One subscription/bill row on the recurring card. */
|
||||
function RecurringRow({ sub, icon }: { sub: Subscription; icon: IconName }) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "8px 0" }}>
|
||||
<Skeleton height="40px" width="40px" style={{ borderRadius: 13, flexShrink: 0 }} />
|
||||
<Skeleton height="1em" width="55%" />
|
||||
<IconChip icon={icon} tint="var(--seed-color-bg-neutral-subtle, #eef0f2)" fg="var(--seed-color-fg-neutral)" size={36} />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
function Dot({ color }: { color: string }) {
|
||||
return <span aria-hidden style={{ display: "inline-block", width: 7, height: 7, borderRadius: 2, background: color, marginRight: 5 }} />;
|
||||
}
|
||||
|
||||
/** 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() {
|
||||
const netWorthQ = useNetWorth();
|
||||
const monthQ = useAnalyzeMonth();
|
||||
const todayQ = useAnalyzeToday();
|
||||
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({
|
||||
netWorth: netWorthQ.data,
|
||||
month: monthQ.data,
|
||||
today: todayQ.data,
|
||||
budget: budgetQ.data,
|
||||
});
|
||||
const heroLoading = netWorthQ.isLoading || monthQ.isLoading || budgetQ.isLoading;
|
||||
|
||||
const built = buildHome({ netWorth: netWorthQ.data, month: monthQ.data, today: undefined, budget: budgetQ.data });
|
||||
const data: HomeData = built ?? sample;
|
||||
const hasRealMonth = built !== null;
|
||||
|
||||
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;
|
||||
|
||||
// Genuinely sparse month: real backend data loaded (`built` succeeded, so
|
||||
// this isn't the full pre-connection sample), but no discretionary spend
|
||||
// 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 noRealPayees =
|
||||
(monthQ.data?.incomePayees?.length ?? 0) === 0 && (monthQ.data?.expensePayees?.length ?? 0) === 0;
|
||||
const sparseMonth = built !== null && data.monthlyExpense === 0 && noRealPayees;
|
||||
const trendMonths = buildTrendMonths(trendQ.data?.months);
|
||||
const verdict = buildCashFlowVerdict(dec(monthQ.data?.income), dec(monthQ.data?.expense));
|
||||
|
||||
const monthTxns = monthTxnsQ.data ?? [];
|
||||
const uncategorizedCount = countUncategorized(monthTxns);
|
||||
|
||||
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 (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 18 }}>
|
||||
|
|
@ -134,165 +261,275 @@ export function DashboardView() {
|
|||
</div>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-1 gap-[18px] md:grid-cols-2 md:items-start md:gap-5">
|
||||
{/* 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: "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
|
||||
{/* 5. Categorize nudge — the memo's keystone: made visible at the top,
|
||||
not tucked in as the last card. A slim banner, not a full module. */}
|
||||
{!monthTxnsQ.isLoading && uncategorizedCount > 0 && (
|
||||
<Link
|
||||
href="/accounting/review"
|
||||
style={{
|
||||
marginTop: 14,
|
||||
height: 6,
|
||||
borderRadius: 999,
|
||||
background: "rgba(0,0,0,0.13)",
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 12,
|
||||
textDecoration: "none",
|
||||
color: "var(--mercury-on-brand)",
|
||||
background: "var(--mercury-warning-chip)",
|
||||
borderRadius: "var(--seed-radius-r3)",
|
||||
padding: "10px 14px",
|
||||
}}
|
||||
>
|
||||
<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={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 style={{ display: "flex", alignItems: "center", gap: 10, minWidth: 0 }}>
|
||||
<IconChip icon="list" tint="rgba(0,0,0,0.08)" fg="var(--mercury-on-brand)" size={34} />
|
||||
<span style={{ fontSize: 14, fontWeight: 700 }}>{s.nudge(uncategorizedCount)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<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"
|
||||
<span
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--mercury-on-brand)",
|
||||
background: "var(--mercury-warning-chip)",
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
flexShrink: 0,
|
||||
padding: "7px 14px",
|
||||
borderRadius: 999,
|
||||
padding: "8px 12px",
|
||||
textDecoration: "none",
|
||||
background: "var(--mercury-on-brand)",
|
||||
color: "var(--mercury-brand-yellow)",
|
||||
}}
|
||||
>
|
||||
{s.viewAll} ›
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
{s.nudgeCta} ›
|
||||
</span>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Recent income / expense ledgers. */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
{loading ? (
|
||||
<>
|
||||
<Card style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<SectionHeader title={s.income} />
|
||||
<LedgerRowSkeleton />
|
||||
</Card>
|
||||
<Card style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<SectionHeader title={s.expense} />
|
||||
<LedgerRowSkeleton />
|
||||
</Card>
|
||||
</>
|
||||
) : sparseMonth ? (
|
||||
<Card>
|
||||
<div className="grid grid-cols-1 gap-[18px] md:grid-cols-2 md:items-start md:gap-5">
|
||||
{/* 1. Safe-to-spend + cash-flow hero. */}
|
||||
<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: "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={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 />
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<Card style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<SectionHeader title={s.income} />
|
||||
{data.income.map((row, i) => (
|
||||
<LedgerEntryRow key={i} row={row} income />
|
||||
) : (
|
||||
<>
|
||||
{topCategories.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.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>
|
||||
<Card style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<SectionHeader title={s.expense} />
|
||||
{data.expense.map((row, i) => (
|
||||
<LedgerEntryRow key={i} row={row} income={false} />
|
||||
))}
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 4. Цэвэр хөрөнгө — net worth + composition. */}
|
||||
<Card style={{ display: "flex", flexDirection: "column", gap: 14 }}>
|
||||
<SectionHeader title={s.netWorthTitle} />
|
||||
{netWorthLoading ? (
|
||||
<Skeleton height="1.8em" width="160px" />
|
||||
) : (
|
||||
<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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ 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 {
|
||||
export function dec(s?: string | null): number {
|
||||
if (!s) return 0;
|
||||
const n = Number(s);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
|
|
|
|||
35
src/features/home/cashFlowTrend.test.ts
Normal file
35
src/features/home/cashFlowTrend.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
43
src/features/home/cashFlowTrend.ts
Normal file
43
src/features/home/cashFlowTrend.ts
Normal 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) };
|
||||
}
|
||||
23
src/features/home/categorizeNudge.test.ts
Normal file
23
src/features/home/categorizeNudge.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
8
src/features/home/categorizeNudge.ts
Normal file
8
src/features/home/categorizeNudge.ts
Normal 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;
|
||||
}
|
||||
32
src/features/home/netWorthComposition.test.ts
Normal file
32
src/features/home/netWorthComposition.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
25
src/features/home/netWorthComposition.ts
Normal file
25
src/features/home/netWorthComposition.ts
Normal 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 };
|
||||
}
|
||||
61
src/features/home/recurring.test.ts
Normal file
61
src/features/home/recurring.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
45
src/features/home/recurring.ts
Normal file
45
src/features/home/recurring.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -1,18 +1,42 @@
|
|||
// Home dashboard copy, ported verbatim from
|
||||
// ios/Mercury/Features/Home/DashboardView.swift.
|
||||
// Home dashboard copy. The "money clarity" rebuild (mercury-rethink memo):
|
||||
// cash-flow hero, where-it-went, recurring, net worth, categorize nudge.
|
||||
export const homeStrings = {
|
||||
wordmark: "MERCURY",
|
||||
hideAmounts: "Мөнгөн дүн нуух",
|
||||
|
||||
// 1. Safe-to-spend + cash-flow hero.
|
||||
safeToSpend: "Энэ сар зарцуулж болох",
|
||||
overspent: "Төсвөөс хэтэрсэн",
|
||||
spent: "Зарцуулсан",
|
||||
dailyLimit: "Өнөөдрийн лимит",
|
||||
thisMonth: "Энэ сард",
|
||||
expenseLabel: "зарлага",
|
||||
viewAll: "Бүгдийг харах",
|
||||
hideAmounts: "Мөнгөн дүн нуух",
|
||||
income: "Орлого",
|
||||
expense: "Зарлага",
|
||||
todaySpent: "Өнөөдөр зарцуулсан",
|
||||
verdictPositive: "Энэ сар орлого зарлагаа даасан",
|
||||
verdictNegative: (amount: string) => `Энэ сар ${amount}-р илүү зарцуулсан`,
|
||||
trendAria: "Сүүлийн 6 сарын орлого, зарлагын харьцуулалт",
|
||||
|
||||
// Categorize nudge.
|
||||
nudge: (count: number) => `${count} гүйлгээ ангилаагүй байна`,
|
||||
nudgeCta: "Ангилах",
|
||||
|
||||
// 2. Хаана зарцуулсан бэ?
|
||||
whereWentTitle: "Хаана зарцуулсан бэ?",
|
||||
categoriesLabel: "Ангилал",
|
||||
merchantsLabel: "Худалдагч",
|
||||
noSpendTitle: "Энэ сар хараахан зарлага алга",
|
||||
noSpendHint: "Гүйлгээ хийгдмэгц энд харагдана.",
|
||||
|
||||
// 3. Тогтмол төлбөр.
|
||||
recurringTitle: "Тогтмол төлбөр",
|
||||
perMonth: "/ сар",
|
||||
detectedRecurringLabel: "Илэрсэн тогтмол",
|
||||
detectedTimes: (count: number) => `Энэ сард ${count} удаа`,
|
||||
noSubsTitle: "Тогтмол төлбөр алга",
|
||||
noSubsHint: "Захиалга, дансны төлбөрүүд энд харагдана.",
|
||||
|
||||
// 4. Цэвэр хөрөнгө.
|
||||
netWorthTitle: "Цэвэр хөрөнгө",
|
||||
bankAccountsLabel: "Банкны данс",
|
||||
manualAssetsLabel: "Бусад хөрөнгө",
|
||||
liabilitiesLabel: "Өр төлбөр",
|
||||
viewAssets: "Хөрөнгийн жагсаалт",
|
||||
} as const;
|
||||
|
|
|
|||
49
src/features/home/whereItWent.test.ts
Normal file
49
src/features/home/whereItWent.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
40
src/features/home/whereItWent.ts
Normal file
40
src/features/home/whereItWent.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -21,6 +21,18 @@ export const analyzeToday = { from: "2026-08-22", to: "2026-08-22", income: "0",
|
|||
expenseCategories: [{ 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 categories = [category];
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ export const handlers = [
|
|||
// vs useAnalyzeToday in src/api/hooks/reads.ts.
|
||||
http.get("/api/v1/analyze", ({ request }) => {
|
||||
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");
|
||||
return HttpResponse.json(isToday ? fixtures.analyzeToday : fixtures.analyzeMonth);
|
||||
}),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue