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 * as React from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { Skeleton } from "@seed-design/react";
|
import { Skeleton } from "@seed-design/react";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
|
|
@ -11,11 +12,11 @@ import {
|
||||||
Icon,
|
Icon,
|
||||||
HideAmountsToggle,
|
HideAmountsToggle,
|
||||||
SyncButton,
|
SyncButton,
|
||||||
|
MercuryButton,
|
||||||
categoryStyle,
|
categoryStyle,
|
||||||
} from "../../ds";
|
} from "../../ds";
|
||||||
import type { IconName } from "../../ds/icons";
|
import type { IconName } from "../../ds/icons";
|
||||||
import { AmountToggle } from "../../ds/AmountToggle";
|
import { tugrik, tugrikRaw, tugrikShort, tugrikShortRaw, MASKED } from "../../ds/money";
|
||||||
import { tugrik, tugrikShort, tugrikShortRaw } from "../../ds/money";
|
|
||||||
import {
|
import {
|
||||||
useNetWorth,
|
useNetWorth,
|
||||||
useAnalyzeMonth,
|
useAnalyzeMonth,
|
||||||
|
|
@ -32,18 +33,54 @@ import { topExpenseCategories, topExpensePayees, shareOf } from "./whereItWent";
|
||||||
import { recurringMonthlyTotal, detectRecurringMerchants } from "./recurring";
|
import { recurringMonthlyTotal, detectRecurringMerchants } from "./recurring";
|
||||||
import { buildNetWorthComposition } from "./netWorthComposition";
|
import { buildNetWorthComposition } from "./netWorthComposition";
|
||||||
import { countUncategorized } from "./categorizeNudge";
|
import { countUncategorized } from "./categorizeNudge";
|
||||||
|
import { prettyMerchant } from "./prettyMerchant";
|
||||||
import { sample } from "./sample";
|
import { sample } from "./sample";
|
||||||
import { homeStrings as s } from "./strings";
|
import { homeStrings as s } from "./strings";
|
||||||
import { monthRange } from "../accounting/monthRange";
|
import { monthRange } from "../accounting/monthRange";
|
||||||
import { useHiddenAmounts } from "../accounting/useHiddenAmounts";
|
import { useHiddenAmounts } from "../accounting/useHiddenAmounts";
|
||||||
|
|
||||||
/** A number that's redacted with a Seed skeleton block while loading, and a
|
/** A number that's redacted with a Seed skeleton block while loading. Once
|
||||||
* tap-to-reveal `AmountToggle` once real data has arrived. Mirrors iOS's
|
* real data has arrived it renders the REAL figure by default — masked only
|
||||||
* `.skeleton(loading)` view modifier, which redacts the finished layout in
|
* when the global hide-amounts flag (`hidden`) is on — never the
|
||||||
* place rather than swapping in a separate spinner. */
|
* default-hidden tap-to-reveal `AmountToggle`, which is for the ledger rows,
|
||||||
function Amount({ value, loading, width = "72px" }: { value: number; loading: boolean; width?: string }) {
|
* 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" }} />;
|
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). */
|
/** 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[] }) {
|
function CashFlowMiniChart({ months }: { months: TrendMonth[] }) {
|
||||||
const max = Math.max(1, ...months.flatMap((m) => [m.income, m.expense]));
|
const max = Math.max(1, ...months.flatMap((m) => [m.income, m.expense]));
|
||||||
const W = 300;
|
const W = 300;
|
||||||
const H = 78;
|
const H = 92;
|
||||||
const base = H - 14;
|
const base = H - 16;
|
||||||
const top = 6;
|
const top = 8;
|
||||||
const groupW = W / months.length;
|
const groupW = W / months.length;
|
||||||
const barW = Math.min(16, groupW / 3.2);
|
const barW = Math.min(16, groupW / 3.2);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div style={{ paddingTop: 2 }}>
|
||||||
<div style={{ display: "flex", gap: 14, fontSize: 10, opacity: 0.75, marginBottom: 4 }}>
|
<div style={{ display: "flex", gap: 14, fontSize: 10, opacity: 0.75, marginBottom: 6 }}>
|
||||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 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" }} />
|
<i aria-hidden style={{ width: 7, height: 7, borderRadius: 2, background: "#1B8F60", display: "inline-block" }} />
|
||||||
{s.income}
|
{s.income}
|
||||||
|
|
@ -166,7 +203,7 @@ function MerchantShareRow({ item, items }: { item: Named; items: Named[] }) {
|
||||||
color: "var(--seed-color-fg-neutral)",
|
color: "var(--seed-color-fg-neutral)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{item.name}
|
{prettyMerchant(item.name)}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ fontSize: 14, fontWeight: 700, flexShrink: 0 }}>{tugrik(item.total)}</span>
|
<span style={{ fontSize: 14, fontWeight: 700, flexShrink: 0 }}>{tugrik(item.total)}</span>
|
||||||
</div>
|
</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} />
|
<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" }}>
|
<div style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column" }}>
|
||||||
<span style={{ fontSize: 14, fontWeight: 700, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
<span style={{ fontSize: 14, fontWeight: 700, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||||
{sub.label}
|
{prettyMerchant(sub.label)}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ fontSize: 12, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}>{sub.cadence}</span>
|
<span style={{ fontSize: 12, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}>{sub.cadence}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -203,6 +240,7 @@ function Dot({ color }: { color: string }) {
|
||||||
* unchanged) plus the small aggregation helpers in this directory; `sample`
|
* unchanged) plus the small aggregation helpers in this directory; `sample`
|
||||||
* is the pre-connection / empty-account fallback for the hero only. */
|
* is the pre-connection / empty-account fallback for the hero only. */
|
||||||
export function DashboardView() {
|
export function DashboardView() {
|
||||||
|
const router = useRouter();
|
||||||
const netWorthQ = useNetWorth();
|
const netWorthQ = useNetWorth();
|
||||||
const monthQ = useAnalyzeMonth();
|
const monthQ = useAnalyzeMonth();
|
||||||
const budgetQ = useBudget();
|
const budgetQ = useBudget();
|
||||||
|
|
@ -213,8 +251,9 @@ export function DashboardView() {
|
||||||
|
|
||||||
// Subscribes this component to the global hide-amounts flag so every
|
// Subscribes this component to the global hide-amounts flag so every
|
||||||
// `tugrik()`/`tugrikShort()` call below (which read the flag internally,
|
// `tugrik()`/`tugrikShort()` call below (which read the flag internally,
|
||||||
// but don't themselves trigger a re-render) reflects a live toggle.
|
// but don't themselves trigger a re-render) reflects a live toggle, and so
|
||||||
useHiddenAmounts();
|
// the hero's own `<Amount hidden>` prop stays in sync with it.
|
||||||
|
const hiddenAmounts = useHiddenAmounts();
|
||||||
|
|
||||||
const heroLoading = netWorthQ.isLoading || monthQ.isLoading || budgetQ.isLoading;
|
const heroLoading = netWorthQ.isLoading || monthQ.isLoading || budgetQ.isLoading;
|
||||||
|
|
||||||
|
|
@ -246,6 +285,21 @@ export function DashboardView() {
|
||||||
);
|
);
|
||||||
const detected = detectRecurringMerchants(monthTxns, knownMatchKeys);
|
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 composition = buildNetWorthComposition(netWorthQ.data);
|
||||||
const netWorthLoading = netWorthQ.isLoading;
|
const netWorthLoading = netWorthQ.isLoading;
|
||||||
const compTotal = composition.bankTotal + composition.manualTotal;
|
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,
|
{/* 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. */}
|
not tucked in as the last card. A slim banner, not a full module. */}
|
||||||
{!monthTxnsQ.isLoading && uncategorizedCount > 0 && (
|
{!monthTxnsQ.isLoading && uncategorizedCount > 0 && (
|
||||||
<Link
|
<div
|
||||||
href="/accounting/review"
|
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
gap: 12,
|
gap: 12,
|
||||||
textDecoration: "none",
|
|
||||||
color: "var(--mercury-on-brand)",
|
|
||||||
background: "var(--mercury-warning-chip)",
|
background: "var(--mercury-warning-chip)",
|
||||||
borderRadius: "var(--seed-radius-r3)",
|
borderRadius: "var(--seed-radius-r3)",
|
||||||
padding: "10px 14px",
|
padding: "10px 12px 10px 10px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 10, minWidth: 0 }}>
|
<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} />
|
<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>
|
</div>
|
||||||
<span
|
<MercuryButton
|
||||||
|
variant="primary"
|
||||||
|
size="small"
|
||||||
|
onClick={() => router.push("/accounting/review")}
|
||||||
style={{
|
style={{
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 700,
|
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
padding: "7px 14px",
|
|
||||||
borderRadius: 999,
|
|
||||||
background: "var(--mercury-on-brand)",
|
background: "var(--mercury-on-brand)",
|
||||||
color: "var(--mercury-brand-yellow)",
|
color: "var(--mercury-brand-yellow)",
|
||||||
|
borderColor: "transparent",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{s.nudgeCta} ›
|
{s.nudgeCta}
|
||||||
</span>
|
</MercuryButton>
|
||||||
</Link>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-[18px] md:grid-cols-2 md:items-start md:gap-5">
|
<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)",
|
color: "var(--mercury-on-brand)",
|
||||||
background: "var(--mercury-balance-card)",
|
background: "var(--mercury-balance-card)",
|
||||||
borderRadius: "var(--seed-radius-r5, 20px)",
|
borderRadius: "var(--seed-radius-r5, 20px)",
|
||||||
padding: "18px 20px",
|
padding: "20px 22px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
|
||||||
<TugrikCircle bg="var(--mercury-balance-circle)" fg="var(--mercury-on-brand)" />
|
<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: 12, opacity: 0.85 }}>{data.overspent ? s.overspent : s.safeToSpend}</span>
|
||||||
<span style={{ fontSize: 27, fontWeight: 700, lineHeight: 1.15 }}>
|
<span style={{ fontSize: 30, fontWeight: 700, lineHeight: 1.15 }}>
|
||||||
<Amount value={data.safeToSpend} loading={heroLoading} width="140px" />
|
<Amount value={data.safeToSpend} loading={heroLoading} hidden={hiddenAmounts} width="140px" />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<div
|
||||||
style={{
|
style={{
|
||||||
height: "100%",
|
height: "100%",
|
||||||
|
|
@ -333,14 +386,14 @@ export function DashboardView() {
|
||||||
<div style={{ marginTop: 10, display: "flex", justifyContent: "space-between", alignItems: "baseline", fontSize: 12 }}>
|
<div style={{ marginTop: 10, display: "flex", justifyContent: "space-between", alignItems: "baseline", fontSize: 12 }}>
|
||||||
<span style={{ opacity: 0.85 }}>{s.spent}</span>
|
<span style={{ opacity: 0.85 }}>{s.spent}</span>
|
||||||
<span style={{ fontWeight: 700, fontSize: 13 }}>
|
<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 style={{ fontWeight: 400, fontSize: 11, opacity: 0.85 }}> / {tugrikShortRaw(budgetDenominator)}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasRealMonth && (
|
{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} />}
|
{trendMonths.length > 0 && <CashFlowMiniChart months={trendMonths} />}
|
||||||
<div style={{ marginTop: trendMonths.length > 0 ? 10 : 0, fontSize: 13, fontWeight: 700 }}>
|
<div style={{ marginTop: trendMonths.length > 0 ? 10 : 0, fontSize: 13, fontWeight: 700 }}>
|
||||||
<span style={{ color: verdict.positive ? "#1B8F60" : "#A83232" }}>
|
<span style={{ color: verdict.positive ? "#1B8F60" : "#A83232" }}>
|
||||||
|
|
@ -352,7 +405,7 @@ export function DashboardView() {
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* 2. Хаана зарцуулсан бэ? — top categories + top merchants. */}
|
{/* 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} />
|
<SectionHeader title={s.whereWentTitle} />
|
||||||
{whereItWentLoading ? (
|
{whereItWentLoading ? (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
|
|
@ -400,31 +453,29 @@ export function DashboardView() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<ViewAllLink href="/accounting" label={s.viewAll} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 3. Тогтмол төлбөр — recurring & subscriptions. */}
|
{/* 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} />
|
<SectionHeader title={s.recurringTitle} />
|
||||||
{recurringLoading ? (
|
{recurringLoading ? (
|
||||||
<Skeleton height="1.4em" width="140px" />
|
<Skeleton height="1.4em" width="140px" />
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: "flex", alignItems: "baseline", gap: 6 }}>
|
<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>
|
<span style={{ fontSize: 13, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}>{s.perMonth}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!recurringLoading && subs.length === 0 && bills.length === 0 ? (
|
{!recurringLoading && visibleRecurring.length === 0 ? (
|
||||||
<EmptyState icon="sparkles" title={s.noSubsTitle} hint={s.noSubsHint} compact />
|
<EmptyState icon="sparkles" title={s.noSubsTitle} hint={s.noSubsHint} compact />
|
||||||
) : (
|
) : (
|
||||||
!recurringLoading && (
|
!recurringLoading && (
|
||||||
<div style={{ display: "flex", flexDirection: "column" }}>
|
<div style={{ display: "flex", flexDirection: "column" }}>
|
||||||
{subs.map((sub) => (
|
{visibleRecurring.map(({ sub, icon }) => (
|
||||||
<RecurringRow key={sub.matchKey ?? sub.label} sub={sub} icon="sparkles" />
|
<RecurringRow key={sub.matchKey ?? sub.label} sub={sub} icon={icon} />
|
||||||
))}
|
|
||||||
{bills.map((bill) => (
|
|
||||||
<RecurringRow key={bill.matchKey ?? bill.label} sub={bill} icon="card" />
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -454,7 +505,7 @@ export function DashboardView() {
|
||||||
<span
|
<span
|
||||||
style={{ fontSize: 13, fontWeight: 700, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
|
style={{ fontSize: 13, fontWeight: 700, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
|
||||||
>
|
>
|
||||||
{d.label}
|
{prettyMerchant(d.label)}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ fontSize: 11, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}>
|
<span style={{ fontSize: 11, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}>
|
||||||
{s.detectedTimes(d.count)}
|
{s.detectedTimes(d.count)}
|
||||||
|
|
@ -465,10 +516,16 @@ export function DashboardView() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{!recurringLoading && recurringRows.length > 0 && (
|
||||||
|
<ViewAllLink
|
||||||
|
href="/profile/subscriptions"
|
||||||
|
label={hiddenRecurringCount > 0 ? `${s.viewAll} (+${hiddenRecurringCount})` : s.viewAll}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 4. Цэвэр хөрөнгө — net worth + composition. */}
|
{/* 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} />
|
<SectionHeader title={s.netWorthTitle} />
|
||||||
{netWorthLoading ? (
|
{netWorthLoading ? (
|
||||||
<Skeleton height="1.8em" width="160px" />
|
<Skeleton height="1.8em" width="160px" />
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,18 @@
|
||||||
import { render, screen, waitFor } from "@testing-library/react";
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
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 { server } from "../../test/server";
|
||||||
import { DashboardView } from "./DashboardView";
|
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
|
* Integration test: DashboardView wired to real react-query hooks, backed by
|
||||||
* MSW (not a hook mock) serving /networth + /analyze (month & today) +
|
* 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} гүйлгээ ангилаагүй байна`,
|
nudge: (count: number) => `${count} гүйлгээ ангилаагүй байна`,
|
||||||
nudgeCta: "Ангилах",
|
nudgeCta: "Ангилах",
|
||||||
|
|
||||||
|
// Shared "see the full list" link, capped sections on the home cards.
|
||||||
|
viewAll: "Бүгдийг харах",
|
||||||
|
|
||||||
// 2. Хаана зарцуулсан бэ?
|
// 2. Хаана зарцуулсан бэ?
|
||||||
whereWentTitle: "Хаана зарцуулсан бэ?",
|
whereWentTitle: "Хаана зарцуулсан бэ?",
|
||||||
categoriesLabel: "Ангилал",
|
categoriesLabel: "Ангилал",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue