feat(web): income card on home — salary vs support + what's kept

New card on the money-clarity home showing this month's income split into
earned salary and support/other, with a spent-vs-income bar and the save
rate. Surfaces the backend income breakdown so the reliable-earnings-vs-
variable-support distinction is visible, not just in reports.
This commit is contained in:
Munkherdene 2026-08-23 18:19:50 +08:00
parent fdebc97c9a
commit 741d5ca0f4
3 changed files with 70 additions and 3 deletions

View file

@ -4,6 +4,8 @@ export const MonthSchema = z.object({ month: z.string(), income: z.string(), exp
export const AnalyzeSchema = z.object({ from: z.string(), to: z.string(), income: z.string(), expense: z.string(), export const AnalyzeSchema = z.object({ from: z.string(), to: z.string(), income: z.string(), expense: z.string(),
discretionaryExpense: z.string().nullish(), net: z.string(), months: z.array(MonthSchema).nullish(), discretionaryExpense: z.string().nullish(), net: z.string(), months: z.array(MonthSchema).nullish(),
expenseCategories: z.array(NamedSchema).nullish(), incomePayees: z.array(NamedSchema).nullish(), expenseCategories: z.array(NamedSchema).nullish(), incomePayees: z.array(NamedSchema).nullish(),
expensePayees: z.array(NamedSchema).nullish() }); expensePayees: z.array(NamedSchema).nullish(),
salaryIncome: z.string().nullish(), otherIncome: z.string().nullish(),
monthlySalary: z.string().nullish(), nextPayday: z.string().nullish() });
export type Named = z.infer<typeof NamedSchema>; export type Named = z.infer<typeof NamedSchema>;
export type Analyze = z.infer<typeof AnalyzeSchema>; export type Analyze = z.infer<typeof AnalyzeSchema>;

View file

@ -25,7 +25,7 @@ import {
useSubscriptions, useSubscriptions,
useTransactions, useTransactions,
} from "../../api/hooks/reads"; } from "../../api/hooks/reads";
import type { Named } from "../../api/schemas/analyze"; import type { Named, Analyze } from "../../api/schemas/analyze";
import type { Subscription } from "../../api/schemas/subscription"; import type { Subscription } from "../../api/schemas/subscription";
import { buildHome, dec, type HomeData } from "./buildHome"; import { buildHome, dec, type HomeData } from "./buildHome";
import { buildTrendMonths, buildCashFlowVerdict, type TrendMonth } from "./cashFlowTrend"; import { buildTrendMonths, buildCashFlowVerdict, type TrendMonth } from "./cashFlowTrend";
@ -232,6 +232,59 @@ function Dot({ color }: { color: string }) {
return <span aria-hidden style={{ display: "inline-block", width: 7, height: 7, borderRadius: 2, background: color, marginRight: 5 }} />; return <span aria-hidden style={{ display: "inline-block", width: 7, height: 7, borderRadius: 2, background: color, marginRight: 5 }} />;
} }
/** One labelled income component (salary / support) with a leading colour dot. */
function IncomeRow({ label, value, dotColor, loading, hidden }: { label: string; value: number; dotColor: string; loading: boolean; hidden: boolean }) {
return (
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 14 }}>
<span aria-hidden style={{ width: 10, height: 10, borderRadius: "50%", background: dotColor, flex: "none" }} />
{label}
</span>
<span style={{ fontSize: 15, fontWeight: 700 }}>
<Amount value={value} loading={loading} hidden={hidden} />
</span>
</div>
);
}
/** Income card: this month's earnings split into salary vs support/other, and
* how much was kept, so reliable pay reads apart from variable family support. */
function IncomeCard({ month, loading, hidden }: { month?: Analyze; loading: boolean; hidden: boolean }) {
const salary = dec(month?.salaryIncome);
const support = dec(month?.otherIncome);
const income = dec(month?.income);
const expense = dec(month?.expense);
const saved = income - expense;
const rate = income > 0 ? Math.round((saved / income) * 100) : 0;
const spentFrac = income > 0 ? Math.min(1, expense / income) : 0;
const over = saved < 0;
const good = "var(--mercury-success, #1e9e6b)";
const bad = "var(--mercury-critical, #e5484d)";
return (
<Card style={{ display: "flex", flexDirection: "column", gap: 16, padding: 20 }}>
<SectionHeader title={s.incomeTitle} />
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<IncomeRow label={s.incomeSalary} value={salary} dotColor={good} loading={loading} hidden={hidden} />
<IncomeRow label={s.incomeSupport} value={support} dotColor="var(--mercury-brand-yellow, #f2b021)" loading={loading} hidden={hidden} />
</div>
<div>
<div style={{ height: 8, borderRadius: 999, background: "var(--seed-color-bg-neutral-subtle, #eef0f2)", overflow: "hidden" }}>
<div style={{ height: "100%", width: `${Math.max(2, spentFrac * 100)}%`, borderRadius: 999, background: over ? bad : good }} />
</div>
<div style={{ marginTop: 10, display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 8 }}>
<span style={{ fontSize: 13, fontWeight: 700, color: over ? bad : good }}>
{over ? s.incomeOverspent : s.incomeSaveRate(rate)}
</span>
<span style={{ fontSize: 14, fontWeight: 700, color: over ? bad : good, flex: "none" }}>
{over ? "" : "+"}
<Amount value={Math.abs(saved)} loading={loading} hidden={hidden} />
</span>
</div>
</div>
</Card>
);
}
/** The Нүүр (home) "money clarity" dashboard: a slim categorize-nudge banner, /** The Нүүр (home) "money clarity" dashboard: a slim categorize-nudge banner,
* the safe-to-spend + cash-flow hero, where-your-money-went, recurring & * the safe-to-spend + cash-flow hero, where-your-money-went, recurring &
* subscriptions, and net worth + composition. Rebuilt per the mercury-rethink * subscriptions, and net worth + composition. Rebuilt per the mercury-rethink
@ -404,7 +457,10 @@ export function DashboardView() {
)} )}
</Link> </Link>
{/* 2. Хаана зарцуулсан бэ? — top categories + top merchants. */} {/* 2. Income — salary vs support, and what was kept this month. */}
<IncomeCard month={monthQ.data} loading={heroLoading} hidden={hiddenAmounts} />
{/* 3. Хаана зарцуулсан бэ? — top categories + top merchants. */}
<Card style={{ display: "flex", flexDirection: "column", gap: 16, padding: 20 }}> <Card style={{ display: "flex", flexDirection: "column", gap: 16, padding: 20 }}>
<SectionHeader title={s.whereWentTitle} /> <SectionHeader title={s.whereWentTitle} />
{whereItWentLoading ? ( {whereItWentLoading ? (

View file

@ -21,6 +21,15 @@ export const homeStrings = {
// Shared "see the full list" link, capped sections on the home cards. // Shared "see the full list" link, capped sections on the home cards.
viewAll: "Бүгдийг харах", viewAll: "Бүгдийг харах",
// Income card — earned salary vs support, and what's kept.
incomeTitle: "Энэ сарын орлого",
incomeSalary: "Цалин",
incomeSupport: "Дэмжлэг ба бусад",
incomeSaved: "Хэмнэсэн",
incomeSpentOf: "зарцуулсан",
incomeSaveRate: (pct: number) => `Орлогынхоо ${pct}%-г үлдээсэн`,
incomeOverspent: "Энэ сар орлогоосоо илүү зарцуулсан",
// 2. Хаана зарцуулсан бэ? // 2. Хаана зарцуулсан бэ?
whereWentTitle: "Хаана зарцуулсан бэ?", whereWentTitle: "Хаана зарцуулсан бэ?",
categoriesLabel: "Ангилал", categoriesLabel: "Ангилал",