Skip to content

Commit 4b2da19

Browse files
committed
Redesign net worth page with summary paragraph and dynamic chart
1 parent 07c6056 commit 4b2da19

3 files changed

Lines changed: 173 additions & 105 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
### 1.5.3: 2026-03-23
2+
3+
* Redesign net worth page with change summary, dynamic gradient chart, forecast line, and zero reference
4+
* Move net worth to second position in sidebar navigation
5+
16
### 1.5.2: 2026-03-23
27

38
* Add over/under diff as first item in spending flow chart tooltip

src/app/(app)/net-worth/page.tsx

Lines changed: 165 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,11 @@ import {
1010
Area,
1111
XAxis,
1212
YAxis,
13-
CartesianGrid,
1413
Tooltip,
1514
ResponsiveContainer,
15+
ReferenceLine,
16+
ReferenceDot,
1617
} from "recharts";
17-
import { ChartContainer } from "@/components/ui/chart-container";
1818
import { RefreshCw, TrendingUp, TrendingDown, Wallet, Loader2 } from "lucide-react";
1919
import { F } from "@/components/ui/f";
2020

@@ -27,9 +27,14 @@ interface Snapshot {
2727
net_worth: number;
2828
}
2929

30+
function nwColor(value: number): string {
31+
if (value >= 0) return "rgb(74, 222, 128)";
32+
return "rgb(248, 113, 113)";
33+
}
34+
3035
export default function NetWorthPage() {
31-
const { t, fmt, mask } = useLocale();
32-
const { connected } = useYnab();
36+
const { t, locale, fmt, mask } = useLocale();
37+
const { connected, data: ynabData } = useYnab();
3338
const [snapshots, setSnapshots] = useState<Snapshot[]>([]);
3439
const [loading, setLoading] = useState(true);
3540
const [snapshotting, setSnapshotting] = useState(false);
@@ -75,13 +80,54 @@ export default function NetWorthPage() {
7580

7681
const latest = snapshots.length > 0 ? snapshots[snapshots.length - 1] : null;
7782

78-
const chartData = snapshots.map((s) => ({
83+
// Change since earliest available snapshot
84+
const compareSnapshot = snapshots.length >= 2 ? snapshots[0] : null;
85+
const changeAmount = latest && compareSnapshot ? latest.net_worth - compareSnapshot.net_worth : null;
86+
const changeDays = latest && compareSnapshot
87+
? Math.round((new Date(latest.date).getTime() - new Date(compareSnapshot.date).getTime()) / 86400000)
88+
: 0;
89+
90+
// Total investments
91+
const totalInvestments = latest ? latest.investments : 0;
92+
93+
// Chart data — deduplicate per day, keep latest snapshot
94+
const byDay = new Map<string, Snapshot>();
95+
for (const s of snapshots) {
96+
byDay.set(s.date, s);
97+
}
98+
const uniqueSnapshots = [...byDay.values()].sort((a, b) => a.date.localeCompare(b.date));
99+
const chartData: { date: string; netWorth?: number; forecast?: number }[] = uniqueSnapshots.map((s) => ({
79100
date: `${parseInt(s.date.split("-")[2], 10)}.${parseInt(s.date.split("-")[1], 10)}`,
80101
netWorth: Math.round(s.net_worth),
81-
investments: Math.round(s.investments),
82-
debts: Math.round(s.debts),
83102
}));
84103

104+
// Add forecast: project 7 days from last value using weekly trend
105+
if (snapshots.length >= 2 && latest) {
106+
const prevWeek = snapshots.length >= 7 ? snapshots[snapshots.length - 7] : snapshots[0];
107+
const daysBetween = Math.max(1, (new Date(latest.date).getTime() - new Date(prevWeek.date).getTime()) / 86400000);
108+
const dailyTrend = (latest.net_worth - prevWeek.net_worth) / daysBetween;
109+
110+
// Bridge point: last actual value also starts the forecast
111+
chartData[chartData.length - 1].forecast = Math.round(latest.net_worth);
112+
113+
const lastDate = new Date(latest.date);
114+
for (let d = 1; d <= 7; d++) {
115+
const forecastDate = new Date(lastDate);
116+
forecastDate.setDate(forecastDate.getDate() + d);
117+
chartData.push({
118+
date: `${forecastDate.getDate()}.${forecastDate.getMonth() + 1}`,
119+
forecast: Math.round(latest.net_worth + dailyTrend * d),
120+
});
121+
}
122+
}
123+
124+
// Dynamic gradient stops for the line
125+
const actualPoints = chartData.filter((d) => d.netWorth !== undefined);
126+
const gradientStops = actualPoints.map((d, i, arr) => {
127+
const pos = arr.length > 1 ? i / (arr.length - 1) : 0.5;
128+
return { pos, color: nwColor(d.netWorth!) };
129+
});
130+
85131
if (!connected) {
86132
return (
87133
<div className="page-stack">
@@ -95,9 +141,11 @@ export default function NetWorthPage() {
95141
<div className="page-stack">
96142
<div className="page-header-row">
97143
<h1 className="page-heading">{t.dashboard.netWorth}</h1>
98-
<Button variant="outline" size="sm" onClick={takeSnapshot} disabled={snapshotting}>
99-
<RefreshCw className={snapshotting ? "icon-sm animate-spin" : "icon-sm"} />
100-
</Button>
144+
<div className="sync-row">
145+
<Button variant="outline" size="sm" onClick={takeSnapshot} disabled={snapshotting}>
146+
<RefreshCw className={snapshotting ? "icon-sm animate-spin" : "icon-sm"} />
147+
</Button>
148+
</div>
101149
</div>
102150

103151
{loading ? (
@@ -106,22 +154,118 @@ export default function NetWorthPage() {
106154
</div>
107155
) : (
108156
<>
157+
{/* Summary paragraph */}
109158
{latest && (
110-
<div className="net-worth-grid">
111-
<Card className="net-worth-hero">
112-
<p className="net-worth-hero-value" data-positive={latest.net_worth >= 0 || undefined}>
113-
<F v={latest.net_worth} />
114-
</p>
115-
</Card>
159+
<div className="personal-greeting"><p className="personal-greeting-text">
160+
{changeAmount !== null && changeAmount !== 0 && changeDays > 0 ? (
161+
<>
162+
{locale === "fi" ? "Varallisuuden muutos " : "Net worth change "}
163+
<span style={{ color: changeAmount > 0 ? "var(--positive)" : "var(--negative)" }}>
164+
{changeAmount > 0 ? "+" : ""}<F v={changeAmount} s=" €" />
165+
</span>
166+
{locale === "fi" ? ` edellisten ${changeDays} päivän aikana.` : ` over the past ${changeDays} days.`}
167+
</>
168+
) : changeAmount === 0 && changeDays > 0 ? (
169+
<>{locale === "fi" ? "Varallisuus pysynyt samana." : "Net worth stayed the same."}</>
170+
) : (
171+
<>{locale === "fi" ? "Ota tilannekuvia nähdäksesi kehityksen." : "Take snapshots to track progress."}</>
172+
)}
173+
</p></div>
174+
)}
116175

176+
{/* Chart with dynamic line */}
177+
{chartData.length > 1 && (() => {
178+
const lastActualIdx = chartData.reduce((last, d, i) => d.netWorth !== undefined ? i : last, -1);
179+
const lastActualPoint = lastActualIdx >= 0 ? chartData[lastActualIdx] : null;
180+
const lastNw = lastActualPoint?.netWorth ?? 0;
181+
const dotColor = nwColor(lastNw);
182+
const bubbleText = `${fmt(lastNw)} €`;
183+
const bubbleW = bubbleText.length * 5.8 + 14;
184+
185+
/* eslint-disable @typescript-eslint/no-explicit-any */
186+
const renderDotLabel = (props: any) => {
187+
const { viewBox } = props;
188+
if (!viewBox) return null;
189+
const { x, y } = viewBox;
190+
const bh = 20;
191+
const bx = x + 8;
192+
const by = y - bh - 5;
193+
return (
194+
<g>
195+
<path d={`M${bx + 2},${by + bh - 2} L${bx + 9},${by + bh - 2} L${bx + 2},${by + bh + 5} Z`} fill={dotColor} />
196+
<rect x={bx} y={by} width={bubbleW} height={bh} rx={5} fill={dotColor} />
197+
<text x={bx + bubbleW / 2} y={by + bh / 2 + 1} textAnchor="middle" dominantBaseline="middle" fill="#0a0a10" fontSize={11} fontWeight={600} style={{ fontVariantNumeric: "tabular-nums" }}>
198+
{bubbleText}
199+
</text>
200+
</g>
201+
);
202+
};
203+
204+
return (
205+
<div className="spending-flow">
206+
<div className="spending-flow-chart">
207+
<ResponsiveContainer width="100%" height={160}>
208+
<AreaChart data={chartData} margin={{ top: 36, right: 16, left: -20, bottom: 0 }}>
209+
<defs>
210+
<linearGradient id="nwLineGrad" x1="0" y1="0" x2="1" y2="0">
211+
{gradientStops.map((s, i) => (
212+
<stop key={i} offset={`${Math.round(s.pos * 100)}%`} stopColor={s.color} />
213+
))}
214+
</linearGradient>
215+
</defs>
216+
<XAxis dataKey="date" tick={{ fill: "#52525b", fontSize: 9 }} tickLine={false} axisLine={false} interval="preserveStartEnd" />
217+
<YAxis hide domain={[(dataMin: number) => Math.min(dataMin, 0) - 200, (dataMax: number) => Math.max(dataMax, 0) + 200]} />
218+
<ReferenceLine y={0} stroke="rgba(255,255,255,0.15)" strokeDasharray="3 3" />
219+
<Tooltip
220+
content={({ active, payload, label }) => {
221+
if (!active || !payload?.length) return null;
222+
const nw = payload.find((p) => p.dataKey === "netWorth" && p.value != null);
223+
const fc = payload.find((p) => p.dataKey === "forecast" && p.value != null);
224+
if (!nw && !fc) return null;
225+
const val = Number(nw ? nw.value : fc!.value);
226+
const isForecast = !nw;
227+
return (
228+
<div className="chart-tooltip">
229+
<p className="chart-tooltip-label">{label}</p>
230+
<p className="chart-tooltip-value" style={{ color: nwColor(val) }}>
231+
{isForecast ? (locale === "fi" ? "Ennuste: " : "Forecast: ") : ""}
232+
{fmt(val)}
233+
</p>
234+
</div>
235+
);
236+
}}
237+
/>
238+
<Area type="monotone" dataKey="forecast" stroke={dotColor} strokeWidth={2} strokeDasharray="6 4" fill="none" dot={false} strokeOpacity={0.4} />
239+
<Area type="monotone" dataKey="netWorth" stroke="url(#nwLineGrad)" strokeWidth={5} fill="none" dot={false} />
240+
{lastActualPoint && (
241+
<ReferenceDot
242+
x={lastActualPoint.date}
243+
y={lastNw}
244+
r={5}
245+
fill="#0a0a10"
246+
stroke={dotColor}
247+
strokeWidth={4}
248+
label={renderDotLabel}
249+
/>
250+
)}
251+
</AreaChart>
252+
</ResponsiveContainer>
253+
</div>
254+
</div>
255+
);
256+
})()}
257+
258+
{/* Stat cards */}
259+
{latest && (
260+
<div className="net-worth-grid">
117261
<Card className="net-worth-card">
118262
<div className="net-worth-card-row">
119263
<div className="net-worth-card-icon" data-color="primary">
120264
<Wallet />
121265
</div>
122266
<div>
123267
<p className="net-worth-card-label">{t.dashboard.accounts}</p>
124-
<p className="net-worth-card-value"><F v={latest.checking + latest.savings} /></p>
268+
<p className="net-worth-card-value"><F v={latest.checking + latest.savings} s=" €" /></p>
125269
</div>
126270
</div>
127271
</Card>
@@ -133,7 +277,7 @@ export default function NetWorthPage() {
133277
</div>
134278
<div>
135279
<p className="net-worth-card-label">{t.dashboard.investments}</p>
136-
<p className="net-worth-card-value text-positive"><F v={latest.investments} /></p>
280+
<p className="net-worth-card-value text-positive"><F v={latest.investments} s=" €" /></p>
137281
</div>
138282
</div>
139283
</Card>
@@ -145,49 +289,18 @@ export default function NetWorthPage() {
145289
</div>
146290
<div>
147291
<p className="net-worth-card-label">{t.debts.title}</p>
148-
<p className="net-worth-card-value text-negative"><F v={latest.debts} /></p>
292+
<p className="net-worth-card-value text-negative"><F v={latest.debts} s=" €" /></p>
149293
</div>
150294
</div>
151295
</Card>
152296
</div>
153297
)}
154298

155-
{chartData.length > 1 && (
156-
<Card className="net-worth-card net-worth-chart-card">
157-
<ChartContainer height={300}>
158-
<ResponsiveContainer width="100%" height="100%">
159-
<AreaChart data={chartData} margin={{ top: 4, right: 4, left: 0, bottom: 0 }}>
160-
<defs>
161-
<linearGradient id="nwGrad" x1="0" y1="0" x2="0" y2="1">
162-
<stop offset="0%" stopColor="#818cf8" stopOpacity={0.3} />
163-
<stop offset="100%" stopColor="#818cf8" stopOpacity={0} />
164-
</linearGradient>
165-
</defs>
166-
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" vertical={false} />
167-
<XAxis dataKey="date" tick={{ fill: "#71717a", fontSize: 11 }} tickLine={false} axisLine={false} />
168-
<YAxis tick={{ fill: "#71717a", fontSize: 10 }} tickLine={false} axisLine={false} tickFormatter={(v) => mask(v >= 1000 ? `${(v/1000).toFixed(0)}k €` : `${Math.round(v)} €`)} width={50} domain={["auto", "auto"]} />
169-
<Tooltip
170-
content={({ active, payload, label }) =>
171-
active && payload?.length ? (
172-
<div className="chart-tooltip">
173-
<p className="chart-tooltip-label">{label}</p>
174-
<p className="chart-tooltip-value text-foreground">{fmt(Number(payload[0].value))}</p>
175-
</div>
176-
) : null
177-
}
178-
/>
179-
<Area type="monotone" dataKey="netWorth" stroke="#818cf8" strokeWidth={2} fill="url(#nwGrad)" />
180-
</AreaChart>
181-
</ResponsiveContainer>
182-
</ChartContainer>
183-
</Card>
184-
)}
185-
186299
{chartData.length <= 1 && (
187300
<p className="page-subtitle">
188301
{snapshots.length === 0
189-
? "Take your first snapshot to start tracking net worth over time."
190-
: "Keep taking snapshots to see your net worth trend."}
302+
? (locale === "fi" ? "Ota ensimmäinen tilannekuva aloittaaksesi varallisuuden seuranta." : "Take your first snapshot to start tracking net worth over time.")
303+
: (locale === "fi" ? "Jatka tilannekuvien ottamista nähdäksesi varallisuuden kehityksen." : "Keep taking snapshots to see your net worth trend.")}
191304
</p>
192305
)}
193306
</>

src/styles/modules/net-worth.css

Lines changed: 3 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
/* Module: net-worth — minimal */
1+
/* Module: net-worth */
2+
23

34
.net-worth-section {
45
display: flex;
@@ -22,57 +23,10 @@
2223

2324
@media (min-width: 1024px) {
2425
.net-worth-grid {
25-
grid-template-columns: repeat(4, 1fr);
26-
}
27-
}
28-
29-
/* Hero card */
30-
.card.net-worth-hero {
31-
border: none;
32-
background: transparent;
33-
backdrop-filter: none;
34-
padding: 0 0.25rem;
35-
gap: 0.125rem;
36-
grid-column: 1 / -1;
37-
}
38-
39-
.card.net-worth-hero:hover {
40-
border-color: transparent;
41-
}
42-
43-
@media (min-width: 1024px) {
44-
.card.net-worth-hero {
45-
grid-column: span 1;
46-
}
47-
}
48-
49-
.net-worth-hero-label {
50-
font-size: 0.8125rem;
51-
font-weight: 500;
52-
color: var(--muted-foreground);
53-
text-transform: uppercase;
54-
letter-spacing: 0.05em;
55-
}
56-
57-
.net-worth-hero-value {
58-
font-size: 2rem;
59-
line-height: 1.2;
60-
font-weight: 700;
61-
letter-spacing: -0.04em;
62-
color: var(--negative);
63-
font-variant-numeric: tabular-nums;
64-
padding: 0.25rem 0;
65-
}
66-
67-
@media (min-width: 768px) {
68-
.net-worth-hero-value {
69-
font-size: 2.25rem;
26+
grid-template-columns: repeat(3, 1fr);
7027
}
7128
}
7229

73-
.net-worth-hero-value[data-positive] {
74-
color: var(--positive);
75-
}
7630

7731
/* Metric cards — minimal */
7832
.card.net-worth-card {
@@ -180,10 +134,6 @@
180134
color: var(--foreground);
181135
}
182136

183-
.card.net-worth-chart-card {
184-
padding: 1rem;
185-
}
186-
187137
.net-worth-list-amount {
188138
font-size: 0.8125rem;
189139
font-weight: 600;

0 commit comments

Comments
 (0)