@@ -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" ;
1818import { RefreshCw , TrendingUp , TrendingDown , Wallet , Loader2 } from "lucide-react" ;
1919import { 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+
3035export 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 </ >
0 commit comments