fix(web): clean up money-clarity home — unmask hero, cap lists, pretty merchants, breathing room
This commit is contained in:
parent
3f42f21504
commit
e55f0a6283
5 changed files with 161 additions and 50 deletions
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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) +
|
||||
|
|
|
|||
25
src/features/home/prettyMerchant.test.ts
Normal file
25
src/features/home/prettyMerchant.test.ts
Normal 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("");
|
||||
});
|
||||
});
|
||||
17
src/features/home/prettyMerchant.ts
Normal file
17
src/features/home/prettyMerchant.ts
Normal 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()}…`;
|
||||
}
|
||||
|
|
@ -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: "Ангилал",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue