fix(web): clean up money-clarity home — unmask hero, cap lists, pretty merchants, breathing room

This commit is contained in:
Munkherdene 2026-08-23 00:33:11 +08:00
parent 3f42f21504
commit e55f0a6283
5 changed files with 161 additions and 50 deletions

View file

@ -2,6 +2,7 @@
import * as React from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Skeleton } from "@seed-design/react";
import {
Card,
@ -11,11 +12,11 @@ import {
Icon,
HideAmountsToggle,
SyncButton,
MercuryButton,
categoryStyle,
} from "../../ds";
import type { IconName } from "../../ds/icons";
import { AmountToggle } from "../../ds/AmountToggle";
import { tugrik, tugrikShort, tugrikShortRaw } from "../../ds/money";
import { tugrik, tugrikRaw, tugrikShort, tugrikShortRaw, MASKED } from "../../ds/money";
import {
useNetWorth,
useAnalyzeMonth,
@ -32,18 +33,54 @@ import { topExpenseCategories, topExpensePayees, shareOf } from "./whereItWent";
import { recurringMonthlyTotal, detectRecurringMerchants } from "./recurring";
import { buildNetWorthComposition } from "./netWorthComposition";
import { countUncategorized } from "./categorizeNudge";
import { prettyMerchant } from "./prettyMerchant";
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
* `.skeleton(loading)` view modifier, which redacts the finished layout in
* place rather than swapping in a separate spinner. */
function Amount({ value, loading, width = "72px" }: { value: number; loading: boolean; width?: string }) {
/** A number that's redacted with a Seed skeleton block while loading. Once
* real data has arrived it renders the REAL figure by default masked only
* when the global hide-amounts flag (`hidden`) is on never the
* default-hidden tap-to-reveal `AmountToggle`, which is for the ledger rows,
* not the hero. Mirrors iOS's `.skeleton(loading)` view modifier, which
* redacts the finished layout in place rather than swapping in a spinner. */
function Amount({
value,
loading,
hidden,
width = "72px",
}: {
value: number;
loading: boolean;
hidden: boolean;
width?: string;
}) {
if (loading) return <Skeleton height="1em" width={width} style={{ display: "inline-block" }} />;
return <AmountToggle value={value} />;
return <>{hidden ? MASKED : tugrikRaw(value)}</>;
}
/** The small "see the rest" link capping every home list categories,
* merchants, recurring. Always routes to the full-detail page for that
* data, never a dead end. */
function ViewAllLink({ href, label }: { href: string; label: string }) {
return (
<Link
href={href}
style={{
alignSelf: "flex-start",
display: "inline-flex",
alignItems: "center",
gap: 4,
fontSize: 13,
fontWeight: 700,
color: "var(--seed-color-fg-neutral-muted, #8b8b8b)",
textDecoration: "none",
}}
>
{label} <Icon name="chevron-right" size={14} />
</Link>
);
}
/** The circular "₮" badge used on every card (togrogCircle in DashboardView.swift). */
@ -76,15 +113,15 @@ function TugrikCircle({ bg, fg }: { bg: string; fg: string }) {
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 H = 92;
const base = H - 16;
const top = 8;
const groupW = W / months.length;
const barW = Math.min(16, groupW / 3.2);
return (
<div>
<div style={{ display: "flex", gap: 14, fontSize: 10, opacity: 0.75, marginBottom: 4 }}>
<div style={{ paddingTop: 2 }}>
<div style={{ display: "flex", gap: 14, fontSize: 10, opacity: 0.75, marginBottom: 6 }}>
<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}
@ -166,7 +203,7 @@ function MerchantShareRow({ item, items }: { item: Named; items: Named[] }) {
color: "var(--seed-color-fg-neutral)",
}}
>
{item.name}
{prettyMerchant(item.name)}
</span>
<span style={{ fontSize: 14, fontWeight: 700, flexShrink: 0 }}>{tugrik(item.total)}</span>
</div>
@ -182,7 +219,7 @@ function RecurringRow({ sub, icon }: { sub: Subscription; icon: IconName }) {
<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}
{prettyMerchant(sub.label)}
</span>
<span style={{ fontSize: 12, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}>{sub.cadence}</span>
</div>
@ -203,6 +240,7 @@ function Dot({ color }: { color: string }) {
* 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 router = useRouter();
const netWorthQ = useNetWorth();
const monthQ = useAnalyzeMonth();
const budgetQ = useBudget();
@ -213,8 +251,9 @@ export function DashboardView() {
// 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();
// but don't themselves trigger a re-render) reflects a live toggle, and so
// the hero's own `<Amount hidden>` prop stays in sync with it.
const hiddenAmounts = useHiddenAmounts();
const heroLoading = netWorthQ.isLoading || monthQ.isLoading || budgetQ.isLoading;
@ -246,6 +285,21 @@ export function DashboardView() {
);
const detected = detectRecurringMerchants(monthTxns, knownMatchKeys);
// Cap the recurring list at the top 5 by monthly amount (was dumping every
// subscription + bill unfiltered) — biggest commitments first, the rest is
// a tap away via the "Бүгдийг харах" link to the full manager.
const RECURRING_CAP = 5;
const recurringRows = React.useMemo(
() =>
[
...subs.map((sub) => ({ sub, icon: "sparkles" as const })),
...bills.map((sub) => ({ sub, icon: "card" as const })),
].sort((a, b) => dec(b.sub.monthly) - dec(a.sub.monthly)),
[subs, bills],
);
const visibleRecurring = recurringRows.slice(0, RECURRING_CAP);
const hiddenRecurringCount = Math.max(0, recurringRows.length - RECURRING_CAP);
const composition = buildNetWorthComposition(netWorthQ.data);
const netWorthLoading = netWorthQ.isLoading;
const compTotal = composition.bankTotal + composition.manualTotal;
@ -264,38 +318,37 @@ export function DashboardView() {
{/* 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"
<div
style={{
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",
padding: "10px 12px 10px 10px",
}}
>
<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>
<span style={{ fontSize: 14, fontWeight: 700, color: "var(--mercury-on-brand)" }}>
{s.nudge(uncategorizedCount)}
</span>
</div>
<span
<MercuryButton
variant="primary"
size="small"
onClick={() => router.push("/accounting/review")}
style={{
fontSize: 13,
fontWeight: 700,
flexShrink: 0,
padding: "7px 14px",
borderRadius: 999,
background: "var(--mercury-on-brand)",
color: "var(--mercury-brand-yellow)",
borderColor: "transparent",
}}
>
{s.nudgeCta}
</span>
</Link>
{s.nudgeCta}
</MercuryButton>
</div>
)}
<div className="grid grid-cols-1 gap-[18px] md:grid-cols-2 md:items-start md:gap-5">
@ -308,19 +361,19 @@ export function DashboardView() {
color: "var(--mercury-on-brand)",
background: "var(--mercury-balance-card)",
borderRadius: "var(--seed-radius-r5, 20px)",
padding: "18px 20px",
padding: "20px 22px",
}}
>
<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 }}>
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
<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 style={{ fontSize: 30, fontWeight: 700, lineHeight: 1.15 }}>
<Amount value={data.safeToSpend} loading={heroLoading} hidden={hiddenAmounts} width="140px" />
</span>
</div>
</div>
<div style={{ marginTop: 14, height: 6, borderRadius: 999, background: "rgba(0,0,0,0.13)", overflow: "hidden" }}>
<div style={{ marginTop: 16, height: 6, borderRadius: 999, background: "rgba(0,0,0,0.13)", overflow: "hidden" }}>
<div
style={{
height: "100%",
@ -333,14 +386,14 @@ export function DashboardView() {
<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} />
<Amount value={data.monthlyExpense} loading={heroLoading} hidden={hiddenAmounts} />
<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)" }} />
<hr style={{ margin: "18px 0 14px", 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" }}>
@ -352,7 +405,7 @@ export function DashboardView() {
</Link>
{/* 2. Хаана зарцуулсан бэ? — top categories + top merchants. */}
<Card style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<Card style={{ display: "flex", flexDirection: "column", gap: 16, padding: 20 }}>
<SectionHeader title={s.whereWentTitle} />
{whereItWentLoading ? (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
@ -400,31 +453,29 @@ export function DashboardView() {
))}
</div>
)}
<ViewAllLink href="/accounting" label={s.viewAll} />
</>
)}
</Card>
{/* 3. Тогтмол төлбөр — recurring & subscriptions. */}
<Card style={{ display: "flex", flexDirection: "column", gap: 14 }}>
<Card style={{ display: "flex", flexDirection: "column", gap: 14, padding: 20 }}>
<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: 24, 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 ? (
{!recurringLoading && visibleRecurring.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" />
{visibleRecurring.map(({ sub, icon }) => (
<RecurringRow key={sub.matchKey ?? sub.label} sub={sub} icon={icon} />
))}
</div>
)
@ -454,7 +505,7 @@ export function DashboardView() {
<span
style={{ fontSize: 13, fontWeight: 700, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
>
{d.label}
{prettyMerchant(d.label)}
</span>
<span style={{ fontSize: 11, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}>
{s.detectedTimes(d.count)}
@ -465,10 +516,16 @@ export function DashboardView() {
))}
</div>
)}
{!recurringLoading && recurringRows.length > 0 && (
<ViewAllLink
href="/profile/subscriptions"
label={hiddenRecurringCount > 0 ? `${s.viewAll} (+${hiddenRecurringCount})` : s.viewAll}
/>
)}
</Card>
{/* 4. Цэвэр хөрөнгө — net worth + composition. */}
<Card style={{ display: "flex", flexDirection: "column", gap: 14 }}>
<Card style={{ display: "flex", flexDirection: "column", gap: 14, padding: 20 }}>
<SectionHeader title={s.netWorthTitle} />
{netWorthLoading ? (
<Skeleton height="1.8em" width="160px" />

View file

@ -1,9 +1,18 @@
import { render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { describe, it, expect, beforeAll, afterEach, afterAll } from "vitest";
import { describe, it, expect, beforeAll, afterEach, afterAll, vi } from "vitest";
import { server } from "../../test/server";
import { DashboardView } from "./DashboardView";
// DashboardView's categorize-nudge button navigates via `useRouter().push`
// (App Router client hook), which requires a mounted router context this
// plain QueryClientProvider render doesn't provide. Mock it like other
// component tests do (see AuthForm.test.tsx) rather than pull in a full
// router harness.
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), back: vi.fn() }),
}));
/**
* Integration test: DashboardView wired to real react-query hooks, backed by
* MSW (not a hook mock) serving /networth + /analyze (month & today) +

View file

@ -0,0 +1,25 @@
import { describe, it, expect } from "vitest";
import { prettyMerchant } from "./prettyMerchant";
describe("prettyMerchant", () => {
it("collapses masked card-number strings to a clean label", () => {
expect(prettyMerchant("554835******6886:13-08-2026 12:13:52:MCI updates")).toBe("Картын гүйлгээ");
});
it("passes short clean names through unchanged", () => {
expect(prettyMerchant("NOMIN")).toBe("NOMIN");
});
it("truncates long non-card names with an ellipsis, capped at maxLen", () => {
const long = "A very long merchant name that goes on and on and on";
const out = prettyMerchant(long, 20);
expect(out.length).toBeLessThanOrEqual(20);
expect(out.endsWith("…")).toBe(true);
});
it("handles empty/blank/nullish input", () => {
expect(prettyMerchant(undefined)).toBe("");
expect(prettyMerchant(null)).toBe("");
expect(prettyMerchant(" ")).toBe("");
});
});

View file

@ -0,0 +1,17 @@
/** Merchant/payee display-name cleanup for home-screen rows. Raw card-rail
* transaction titles from the bank feed often look like
* "554835******6886:13-08-2026 12:13:52:MCI…" a masked card number glued
* to a timestamp and processor code. That's noise, not a merchant name, so
* collapse it to a plain label; anything else just gets a length cap so a
* long raw string can't blow out a row's layout. */
// A masked card-number fragment: a run of digits, 2+ asterisks, more digits.
const MASKED_CARD_PATTERN = /\d{3,}\*{2,}\d{2,}/;
export function prettyMerchant(name: string | null | undefined, maxLen = 24): string {
const raw = (name ?? "").trim();
if (!raw) return "";
if (MASKED_CARD_PATTERN.test(raw)) return "Картын гүйлгээ";
if (raw.length <= maxLen) return raw;
return `${raw.slice(0, maxLen - 1).trimEnd()}`;
}

View file

@ -18,6 +18,9 @@ export const homeStrings = {
nudge: (count: number) => `${count} гүйлгээ ангилаагүй байна`,
nudgeCta: "Ангилах",
// Shared "see the full list" link, capped sections on the home cards.
viewAll: "Бүгдийг харах",
// 2. Хаана зарцуулсан бэ?
whereWentTitle: "Хаана зарцуулсан бэ?",
categoriesLabel: "Ангилал",