11import { Suspense , lazy , useEffect , useState } from "react" ;
22import { getWatchlist , getPriceHistory , removeFromWatchlist } from "../lib/api/tracking" ;
3- import { getInsights } from "../lib/api/analytics" ;
4- import type { PriceHistoryPoint , PriceInsights , WatchlistItemOut } from "../types" ;
5-
3+ import { getForecast , getInsights } from "../lib/api/analytics" ;
4+ import type { PriceForecast , PriceHistoryPoint , PriceInsights , WatchlistItemOut } from "../types" ;
5+
6+ // React.lazy + dynamic import() splits recharts (and PriceHistoryChart's
7+ // code) into its OWN JS chunk, downloaded only when this component
8+ // actually renders -- i.e. only after a user logs in, opens the
9+ // watchlist, and clicks "View trend." Every other page load (anonymous
10+ // search, login, register) never downloads this chunk at all. This is
11+ // the direct fix for the 630KB bundle warning flagged back in Phase 11.
612const PriceHistoryChart = lazy ( ( ) => import ( "../components/PriceHistoryChart" ) ) ;
713
814const TREND_LABEL : Record < string , string > = {
@@ -12,6 +18,11 @@ const TREND_LABEL: Record<string, string> = {
1218 insufficient_data : "Not enough data yet" ,
1319} ;
1420
21+ // Maps each recommendation/confidence value to one of the three semantic
22+ // badge classes defined once in index.css (badge-success / badge-warning
23+ // / badge-neutral) -- the visual meaning (green=good, amber=caution,
24+ // gray=neutral) is now defined in exactly one place instead of being
25+ // re-derived as a long Tailwind class string per value, per page.
1526const RECOMMENDATION_BADGE_CLASS : Record < string , string > = {
1627 good_time_to_buy : "badge-success" ,
1728 wait : "badge-warning" ,
@@ -24,6 +35,12 @@ const RECOMMENDATION_LABEL: Record<string, string> = {
2435 neutral : "No strong signal" ,
2536} ;
2637
38+ const CONFIDENCE_BADGE_CLASS : Record < string , string > = {
39+ high : "badge-success" ,
40+ moderate : "badge-warning" ,
41+ low : "badge-neutral" ,
42+ } ;
43+
2744function WatchlistItemSkeleton ( ) {
2845 return (
2946 < div className = "card p-5" >
@@ -48,66 +65,42 @@ export function WatchlistPage() {
4865 const [ expandedId , setExpandedId ] = useState < string | null > ( null ) ;
4966 const [ history , setHistory ] = useState < PriceHistoryPoint [ ] > ( [ ] ) ;
5067 const [ insights , setInsights ] = useState < PriceInsights | null > ( null ) ;
68+ const [ forecast , setForecast ] = useState < PriceForecast | null > ( null ) ;
5169 const [ isDetailLoading , setIsDetailLoading ] = useState ( false ) ;
5270
5371 useEffect ( ( ) => {
5472 getWatchlist ( )
55- . then ( ( data ) => {
56- setItems ( Array . isArray ( data ) ? data : [ ] ) ;
57- } )
58- . catch ( ( err ) => console . error ( "Error loading watchlist:" , err ) )
73+ . then ( setItems )
5974 . finally ( ( ) => setIsLoading ( false ) ) ;
6075 } , [ ] ) ;
6176
62- async function handleExpand ( product_id : string ) {
63- if ( ! product_id ) return ;
64-
65- if ( expandedId === product_id ) {
77+ async function handleExpand ( item : WatchlistItemOut ) {
78+ if ( expandedId === item . product . id ) {
6679 setExpandedId ( null ) ;
6780 return ;
6881 }
69-
70- setExpandedId ( product_id ) ;
82+ setExpandedId ( item . product . id ) ;
7183 setIsDetailLoading ( true ) ;
7284 try {
73- const [ historyData , insightsData ] = await Promise . all ( [
74- getPriceHistory ( product_id ) ,
75- getInsights ( product_id ) . catch ( ( ) => null ) ,
85+ const [ historyData , insightsData , forecastData ] = await Promise . all ( [
86+ getPriceHistory ( item . product . id ) ,
87+ getInsights ( item . product . id ) ,
88+ getForecast ( item . product . id ) ,
7689 ] ) ;
77- setHistory ( historyData || [ ] ) ;
90+ setHistory ( historyData ) ;
7891 setInsights ( insightsData ) ;
79- } catch ( err ) {
80- console . error ( "Error loading historic tracking trend metrics:" , err ) ;
92+ setForecast ( forecastData ) ;
8193 } finally {
8294 setIsDetailLoading ( false ) ;
8395 }
8496 }
8597
86- async function handleRemove ( item : any ) {
87- // 🎯 TARGET THE TEXT-BASED PRODUCT_ID (e.g., 'amazon-0' or custom string slug)
88- const targetId = item . product_id || item . product ?. id || item . id ;
89-
90- if ( ! targetId ) {
91- console . warn ( "Remove action dropped: Missing product identifier string." ) ;
92- return ;
93- }
94-
95- try {
96- await removeFromWatchlist ( targetId ) ;
97-
98- // Filter out only the singular item matching our string target ID
99- setItems ( ( prev ) =>
100- prev . filter ( ( i : any ) => {
101- const currentId = i . product_id || i . product ?. id || i . id ;
102- return currentId !== targetId ;
103- } )
104- ) ;
105-
106- if ( expandedId === targetId ) setExpandedId ( null ) ;
107- } catch ( err ) {
108- console . error ( "Failed to remove item from backend watchlist:" , err ) ;
109- }
98+ async function handleRemove ( productId : string ) {
99+ await removeFromWatchlist ( productId ) ;
100+ setItems ( ( prev ) => prev . filter ( ( item ) => item . product . id !== productId ) ) ;
101+ if ( expandedId === productId ) setExpandedId ( null ) ;
110102 }
103+
111104 if ( isLoading ) {
112105 return (
113106 < div className = "page-enter space-y-3" >
@@ -136,75 +129,83 @@ export function WatchlistPage() {
136129 </ p >
137130
138131 < div className = "mt-6 space-y-3" >
139- { items . map ( ( item : any ) => {
140- const trackingProductId = item . product_id || item . product ?. id || item . id ;
141- const title = item . title || item . product ?. title || "Tracked Product" ;
142- const platform = item . platform || item . product ?. platform || "unknown" ;
143- const currency = item . currency || item . product ?. currency || "INR" ;
144- const displayPrice = item . price ?? item . latest_price ?? item . product ?. latest_price ?? item . product ?. price ?? 0 ;
145-
146- return (
147- < div key = { item . id } className = "card" >
148- < div className = "flex items-center justify-between p-5" >
149- < div >
150- < span className = "badge badge-neutral capitalize" > { platform } </ span >
151- < h3 className = "mt-2 text-sm font-medium" > { title } </ h3 >
152- < p className = "mt-1 text-lg font-semibold" >
153- { currency } { displayPrice . toLocaleString ( ) }
154- </ p >
155- </ div >
156- < div className = "flex gap-2" >
157- < button onClick = { ( ) => handleExpand ( trackingProductId ) } className = "btn-secondary" >
158- { expandedId === trackingProductId ? "Hide" : "View trend" }
159- </ button >
160- < button onClick = { ( ) => handleRemove ( item ) } className = "btn-danger-ghost" >
161- Remove
162- </ button >
163- </ div >
132+ { items . map ( ( item ) => (
133+ < div key = { item . id } className = "card" >
134+ < div className = "flex items-center justify-between p-5" >
135+ < div >
136+ < span className = "badge badge-neutral capitalize" > { item . product . platform } </ span >
137+ < h3 className = "mt-2 text-sm font-medium" > { item . product . title } </ h3 >
138+ < p className = "mt-1 text-lg font-semibold" >
139+ { item . product . currency } { item . product . latest_price . toLocaleString ( ) }
140+ </ p >
141+ </ div >
142+ < div className = "flex gap-2" >
143+ < button onClick = { ( ) => handleExpand ( item ) } className = "btn-secondary" >
144+ { expandedId === item . product . id ? "Hide" : "View trend" }
145+ </ button >
146+ < button onClick = { ( ) => handleRemove ( item . product . id ) } className = "btn-danger-ghost" >
147+ Remove
148+ </ button >
164149 </ div >
150+ </ div >
165151
166- { expandedId === trackingProductId && (
167- < div className = "border-t border-slate-100 p-5 dark:border-slate-800" >
168- { isDetailLoading ? (
169- < div className = "space-y-2" >
170- < div className = "skeleton h-4 w-1/3" />
171- < div className = "skeleton h-[220px] w-full" />
172- </ div >
173- ) : (
174- < >
175- { insights && (
176- < div className = "mb-4 flex flex-wrap items-center gap-3 text-sm" >
177- < span className = { `badge ${ RECOMMENDATION_BADGE_CLASS [ insights . recommendation ] } ` } >
178- { RECOMMENDATION_LABEL [ insights . recommendation ] }
179- </ span >
152+ { expandedId === item . product . id && (
153+ < div className = "border-t border-slate-100 p-5 dark:border-slate-800" >
154+ { isDetailLoading ? (
155+ < div className = "space-y-2" >
156+ < div className = "skeleton h-4 w-1/3" />
157+ < div className = "skeleton h-[220px] w-full" />
158+ </ div >
159+ ) : (
160+ < >
161+ { insights && (
162+ < div className = "mb-4 flex flex-wrap items-center gap-3 text-sm" >
163+ < span className = { `badge ${ RECOMMENDATION_BADGE_CLASS [ insights . recommendation ] } ` } >
164+ { RECOMMENDATION_LABEL [ insights . recommendation ] }
165+ </ span >
166+ < span className = "text-slate-500 dark:text-slate-400" >
167+ Trend: { TREND_LABEL [ insights . trend ] }
168+ </ span >
169+ { insights . lowest_price !== null && (
180170 < span className = "text-slate-500 dark:text-slate-400" >
181- Trend: { TREND_LABEL [ insights . trend ] }
171+ Lowest seen: { item . product . currency } { insights . lowest_price . toLocaleString ( ) }
172+ </ span >
173+ ) }
174+ </ div >
175+ ) }
176+
177+ { forecast && forecast . confidence !== "insufficient_data" && (
178+ < div className = "mb-4 rounded-lg bg-brand-50 p-3 text-sm dark:bg-brand-900/20" >
179+ < div className = "mb-1 flex items-center gap-2" >
180+ < span className = "font-medium text-brand-700 dark:text-brand-300" >
181+ 7-day forecast
182+ </ span >
183+ < span className = { `badge ${ CONFIDENCE_BADGE_CLASS [ forecast . confidence ] } ` } >
184+ { forecast . confidence } confidence
182185 </ span >
183- { insights . lowest_price !== null && (
184- < span className = "text-slate-500 dark:text-slate-400" >
185- Lowest seen: { currency } { insights . lowest_price . toLocaleString ( ) }
186- </ span >
187- ) }
188186 </ div >
189- ) }
190-
191- { history && history . length > 1 ? (
192- < Suspense fallback = { < div className = "skeleton h-[220px] w-full" /> } >
193- < PriceHistoryChart history = { history } currency = { currency } />
194- </ Suspense >
195- ) : (
196- < p className = "text-sm text-slate-400" >
197- Not enough price history yet to plot a trend -- check back after a few
198- more price checks.
199- </ p >
200- ) }
201- </ >
202- ) }
203- </ div >
204- ) }
205- </ div >
206- ) ;
207- } ) }
187+ < p className = "text-slate-600 dark:text-slate-300" > { forecast . explanation } </ p >
188+ </ div >
189+ ) }
190+
191+ { history . length > 1 ? (
192+ < Suspense
193+ fallback = { < div className = "skeleton h-[220px] w-full" /> }
194+ >
195+ < PriceHistoryChart history = { history } currency = { item . product . currency } />
196+ </ Suspense >
197+ ) : (
198+ < p className = "text-sm text-slate-400" >
199+ Not enough price history yet to plot a trend -- check back after a few
200+ more price checks.
201+ </ p >
202+ ) }
203+ </ >
204+ ) }
205+ </ div >
206+ ) }
207+ </ div >
208+ ) ) }
208209 </ div >
209210 </ div >
210211 ) ;
0 commit comments