From e55f0a628388b562c6607b13f23ac9262878b1f3 Mon Sep 17 00:00:00 2001 From: Munkherdene Date: Sun, 23 Aug 2026 00:33:11 +0800 Subject: [PATCH] =?UTF-8?q?fix(web):=20clean=20up=20money-clarity=20home?= =?UTF-8?q?=20=E2=80=94=20unmask=20hero,=20cap=20lists,=20pretty=20merchan?= =?UTF-8?q?ts,=20breathing=20room?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/home/DashboardView.tsx | 155 ++++++++++++++++------- src/features/home/dashboard.msw.test.tsx | 11 +- src/features/home/prettyMerchant.test.ts | 25 ++++ src/features/home/prettyMerchant.ts | 17 +++ src/features/home/strings.ts | 3 + 5 files changed, 161 insertions(+), 50 deletions(-) create mode 100644 src/features/home/prettyMerchant.test.ts create mode 100644 src/features/home/prettyMerchant.ts diff --git a/src/features/home/DashboardView.tsx b/src/features/home/DashboardView.tsx index d6c0683..46bc616 100644 --- a/src/features/home/DashboardView.tsx +++ b/src/features/home/DashboardView.tsx @@ -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 ; - return ; + 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 ( + + {label} + + ); } /** 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 ( -
-
+
+
{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)} {tugrik(item.total)}
@@ -182,7 +219,7 @@ function RecurringRow({ sub, icon }: { sub: Subscription; icon: IconName }) {
- {sub.label} + {prettyMerchant(sub.label)} {sub.cadence}
@@ -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 `
)}
@@ -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", }} >
-
+
{data.overspent ? s.overspent : s.safeToSpend} - - + +
-
+
{s.spent} - +
{hasRealMonth && ( <> -
+
{trendMonths.length > 0 && }
0 ? 10 : 0, fontSize: 13, fontWeight: 700 }}> @@ -352,7 +405,7 @@ export function DashboardView() { {/* 2. Хаана зарцуулсан бэ? — top categories + top merchants. */} - + {whereItWentLoading ? (
@@ -400,31 +453,29 @@ export function DashboardView() { ))}
)} + )}
{/* 3. Тогтмол төлбөр — recurring & subscriptions. */} - + {recurringLoading ? ( ) : (
- {tugrik(recurringTotal)} + {tugrik(recurringTotal)} {s.perMonth}
)} - {!recurringLoading && subs.length === 0 && bills.length === 0 ? ( + {!recurringLoading && visibleRecurring.length === 0 ? ( ) : ( !recurringLoading && (
- {subs.map((sub) => ( - - ))} - {bills.map((bill) => ( - + {visibleRecurring.map(({ sub, icon }) => ( + ))}
) @@ -454,7 +505,7 @@ export function DashboardView() { - {d.label} + {prettyMerchant(d.label)} {s.detectedTimes(d.count)} @@ -465,10 +516,16 @@ export function DashboardView() { ))}
)} + {!recurringLoading && recurringRows.length > 0 && ( + 0 ? `${s.viewAll} (+${hiddenRecurringCount})` : s.viewAll} + /> + )} {/* 4. Цэвэр хөрөнгө — net worth + composition. */} - + {netWorthLoading ? ( diff --git a/src/features/home/dashboard.msw.test.tsx b/src/features/home/dashboard.msw.test.tsx index ee26c3c..988d8ce 100644 --- a/src/features/home/dashboard.msw.test.tsx +++ b/src/features/home/dashboard.msw.test.tsx @@ -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) + diff --git a/src/features/home/prettyMerchant.test.ts b/src/features/home/prettyMerchant.test.ts new file mode 100644 index 0000000..93d4e43 --- /dev/null +++ b/src/features/home/prettyMerchant.test.ts @@ -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(""); + }); +}); diff --git a/src/features/home/prettyMerchant.ts b/src/features/home/prettyMerchant.ts new file mode 100644 index 0000000..6279f34 --- /dev/null +++ b/src/features/home/prettyMerchant.ts @@ -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()}…`; +} diff --git a/src/features/home/strings.ts b/src/features/home/strings.ts index fb23a9a..4161eb9 100644 --- a/src/features/home/strings.ts +++ b/src/features/home/strings.ts @@ -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: "Ангилал",