1- import React , { useState , useEffect } from 'react' ;
1+ import React , { useState , useEffect , useMemo } from 'react' ;
22import { loadAllData } from './services/dataLoader' ;
33import OverviewChart from './components/OverviewChart' ;
44import DetailChart from './components/DetailChart' ;
@@ -8,6 +8,19 @@ import NoTeslaToggle from './components/NoTeslaToggle';
88import Footer from './components/Footer' ;
99import { CATEGORY_TABS , DEFAULT_CATEGORY , filterDataByCategory } from './utils/modelCategories' ;
1010
11+ const TIME_RANGE_OPTIONS = [
12+ { id : '7d' , label : '7 Days' , days : 7 } ,
13+ { id : '30d' , label : '30 Days' , days : 30 } ,
14+ { id : '6m' , label : '6 Months' , days : 180 } ,
15+ ] ;
16+
17+ const DEFAULT_RANGE_ID = TIME_RANGE_OPTIONS [ 1 ] . id ;
18+ const MAX_TIME_RANGE_DAYS = TIME_RANGE_OPTIONS [ TIME_RANGE_OPTIONS . length - 1 ] . days ;
19+
20+ function getTimeRangeOption ( rangeId ) {
21+ return TIME_RANGE_OPTIONS . find ( option => option . id === rangeId ) || TIME_RANGE_OPTIONS [ 0 ] ;
22+ }
23+
1124function App ( ) {
1225 const [ data , setData ] = useState ( [ ] ) ;
1326 const [ loading , setLoading ] = useState ( true ) ;
@@ -16,34 +29,106 @@ function App() {
1629 const [ noTesla , setNoTesla ] = useState ( false ) ;
1730 const [ selectedDate , setSelectedDate ] = useState ( null ) ;
1831 const [ selectedCategory , setSelectedCategory ] = useState ( DEFAULT_CATEGORY ) ;
32+ const [ timeRangeId , setTimeRangeId ] = useState ( DEFAULT_RANGE_ID ) ;
33+
34+ const activeRangeOption = useMemo (
35+ ( ) => getTimeRangeOption ( timeRangeId ) ,
36+ [ timeRangeId ]
37+ ) ;
38+
39+ const uniqueDatesDesc = useMemo ( ( ) => {
40+ if ( data . length === 0 ) {
41+ return [ ] ;
42+ }
43+ const dates = [ ...new Set ( data . map ( d => d . scraped_at . split ( 'T' ) [ 0 ] ) ) ] ;
44+ dates . sort ( ( a , b ) => ( a < b ? 1 : a > b ? - 1 : 0 ) ) ;
45+ return dates ;
46+ } , [ data ] ) ;
47+
48+ const mostRecentDate = uniqueDatesDesc . length > 0 ? uniqueDatesDesc [ 0 ] : null ;
49+
50+ const rangeDateLabels = useMemo ( ( ) => {
51+ if ( ! mostRecentDate ) {
52+ return [ ] ;
53+ }
54+
55+ const daysToUse = Math . max ( 1 , activeRangeOption . days ) ;
56+ const labels = [ ] ;
57+ const anchorDate = new Date ( mostRecentDate ) ;
58+
59+ for ( let offset = daysToUse - 1 ; offset >= 0 ; offset -- ) {
60+ const current = new Date ( anchorDate ) ;
61+ current . setDate ( anchorDate . getDate ( ) - offset ) ;
62+ labels . push ( current . toISOString ( ) . split ( 'T' ) [ 0 ] ) ;
63+ }
64+
65+ return labels ;
66+ } , [ mostRecentDate , activeRangeOption ] ) ;
67+
68+ const availableRangeDates = useMemo ( ( ) => {
69+ if ( rangeDateLabels . length === 0 || uniqueDatesDesc . length === 0 ) {
70+ return [ ] ;
71+ }
72+ const availableSet = new Set ( uniqueDatesDesc ) ;
73+ return rangeDateLabels . filter ( date => availableSet . has ( date ) ) ;
74+ } , [ rangeDateLabels , uniqueDatesDesc ] ) ;
75+
76+ useEffect ( ( ) => {
77+ if ( ! mostRecentDate || availableRangeDates . length === 0 ) {
78+ if ( selectedDate !== null ) {
79+ setSelectedDate ( null ) ;
80+ const url = new URL ( window . location ) ;
81+ url . searchParams . delete ( 'date' ) ;
82+ window . history . replaceState ( { } , '' , url ) ;
83+ }
84+ return ;
85+ }
86+
87+ if ( selectedDate && availableRangeDates . includes ( selectedDate ) ) {
88+ return ;
89+ }
90+
91+ const fallbackDate = availableRangeDates [ availableRangeDates . length - 1 ] ;
92+ if ( ! fallbackDate || fallbackDate === selectedDate ) {
93+ return ;
94+ }
95+
96+ setSelectedDate ( fallbackDate ) ;
97+
98+ const url = new URL ( window . location ) ;
99+ if ( fallbackDate === mostRecentDate ) {
100+ url . searchParams . delete ( 'date' ) ;
101+ } else {
102+ url . searchParams . set ( 'date' , fallbackDate ) ;
103+ }
104+
105+ window . history . replaceState ( { } , '' , url ) ;
106+ } , [ selectedDate , availableRangeDates , mostRecentDate ] ) ;
19107
20108 useEffect ( ( ) => {
21- loadAllData ( )
109+ let isMounted = true ;
110+
111+ loadAllData ( MAX_TIME_RANGE_DAYS )
22112 . then ( results => {
113+ if ( ! isMounted ) {
114+ return ;
115+ }
116+
23117 setData ( results ) ;
24118 setLoading ( false ) ;
25119
26- // Get the most recent date
27- const dates = results . length > 0
120+ const uniqueDates = results . length > 0
28121 ? [ ...new Set ( results . map ( d => d . scraped_at . split ( 'T' ) [ 0 ] ) ) ] . sort ( ) . reverse ( )
29122 : [ ] ;
30- const mostRecentDate = dates [ 0 ] ;
123+ const mostRecentDate = uniqueDates [ 0 ] || null ;
31124
32- // Load from URL
33125 const url = new URL ( window . location ) ;
34126
35- // Load date from URL, or use most recent
36- const dateParam = url . searchParams . get ( 'date' ) ;
37- if ( dateParam && dates . includes ( dateParam ) ) {
38- setSelectedDate ( dateParam ) ;
39- } else if ( mostRecentDate ) {
40- setSelectedDate ( mostRecentDate ) ;
41- }
42-
43127 const modelParam = url . searchParams . get ( 'model' ) ;
44128 if ( modelParam && modelParam !== 'all' ) {
45129 setSelectedModel ( modelParam ) ;
46130 }
131+
47132 const noTeslaParam = url . searchParams . get ( 'noTesla' ) ;
48133 if ( noTeslaParam === 'true' ) {
49134 setNoTesla ( true ) ;
@@ -55,11 +140,42 @@ function App() {
55140 } else {
56141 setSelectedCategory ( DEFAULT_CATEGORY ) ;
57142 }
143+
144+ const rangeParam = url . searchParams . get ( 'range' ) ;
145+ const validRangeIds = TIME_RANGE_OPTIONS . map ( option => option . id ) ;
146+ const resolvedRangeId = rangeParam && validRangeIds . includes ( rangeParam )
147+ ? rangeParam
148+ : DEFAULT_RANGE_ID ;
149+ setTimeRangeId ( resolvedRangeId ) ;
150+
151+ if ( rangeParam && rangeParam !== resolvedRangeId ) {
152+ const updatedUrl = new URL ( window . location ) ;
153+ if ( resolvedRangeId === DEFAULT_RANGE_ID ) {
154+ updatedUrl . searchParams . delete ( 'range' ) ;
155+ } else {
156+ updatedUrl . searchParams . set ( 'range' , resolvedRangeId ) ;
157+ }
158+ window . history . replaceState ( { } , '' , updatedUrl ) ;
159+ }
160+
161+ const dateParam = url . searchParams . get ( 'date' ) ;
162+ if ( dateParam && uniqueDates . includes ( dateParam ) ) {
163+ setSelectedDate ( dateParam ) ;
164+ } else if ( mostRecentDate ) {
165+ setSelectedDate ( mostRecentDate ) ;
166+ }
58167 } )
59168 . catch ( err => {
169+ if ( ! isMounted ) {
170+ return ;
171+ }
60172 setError ( err . message ) ;
61173 setLoading ( false ) ;
62174 } ) ;
175+
176+ return ( ) => {
177+ isMounted = false ;
178+ } ;
63179 } , [ ] ) ;
64180
65181 // Handle browser back/forward
@@ -87,11 +203,20 @@ function App() {
87203 } else {
88204 setSelectedCategory ( DEFAULT_CATEGORY ) ;
89205 }
206+
207+ const rangeParam = url . searchParams . get ( 'range' ) ;
208+ const validRangeIds = TIME_RANGE_OPTIONS . map ( option => option . id ) ;
209+ const fallbackRangeId = rangeParam && validRangeIds . includes ( rangeParam )
210+ ? rangeParam
211+ : DEFAULT_RANGE_ID ;
212+ if ( fallbackRangeId !== timeRangeId ) {
213+ setTimeRangeId ( fallbackRangeId ) ;
214+ }
90215 } ;
91216
92217 window . addEventListener ( 'popstate' , handlePopState ) ;
93218 return ( ) => window . removeEventListener ( 'popstate' , handlePopState ) ;
94- } , [ data ] ) ;
219+ } , [ data , timeRangeId ] ) ;
95220
96221 const handleModelSelect = ( model ) => {
97222 const url = new URL ( window . location ) ;
@@ -131,24 +256,49 @@ function App() {
131256 window . history . pushState ( { } , '' , url ) ;
132257 } ;
133258
134- const handleDateSelect = ( date ) => {
135- setSelectedDate ( date ) ;
259+ const handleTimeRangeChange = ( rangeId , { replaceHistory = false } = { } ) => {
260+ const validRangeIds = TIME_RANGE_OPTIONS . map ( option => option . id ) ;
261+ if ( ! rangeId || rangeId === timeRangeId || ! validRangeIds . includes ( rangeId ) ) {
262+ return ;
263+ }
136264
137- // Get the most recent date to determine if we should include date param
138- const dates = data . length > 0
139- ? [ ...new Set ( data . map ( d => d . scraped_at . split ( 'T' ) [ 0 ] ) ) ] . sort ( ) . reverse ( )
140- : [ ] ;
141- const mostRecentDate = dates [ 0 ] ;
265+ setTimeRangeId ( rangeId ) ;
142266
143267 const url = new URL ( window . location ) ;
144- if ( date === mostRecentDate ) {
145- // If selecting today (most recent), remove the date parameter
268+ if ( rangeId === DEFAULT_RANGE_ID ) {
269+ url . searchParams . delete ( 'range' ) ;
270+ } else {
271+ url . searchParams . set ( 'range' , rangeId ) ;
272+ }
273+ window . history [ replaceHistory ? 'replaceState' : 'pushState' ] ( { } , '' , url ) ;
274+ } ;
275+
276+ const handleDateSelect = ( date , { replaceHistory = false , force = false } = { } ) => {
277+ if ( ! date ) {
278+ return ;
279+ }
280+
281+ if ( ! force && date === selectedDate ) {
282+ return ;
283+ }
284+
285+ if ( ! force && ( availableRangeDates . length === 0 || ! availableRangeDates . includes ( date ) ) ) {
286+ return ;
287+ }
288+
289+ setSelectedDate ( date ) ;
290+
291+ const mostRecentDate = uniqueDatesDesc . length > 0 ? uniqueDatesDesc [ 0 ] : null ;
292+ const url = new URL ( window . location ) ;
293+
294+ if ( mostRecentDate && date === mostRecentDate ) {
146295 url . searchParams . delete ( 'date' ) ;
147296 } else {
148- // Otherwise, set the date parameter
149297 url . searchParams . set ( 'date' , date ) ;
150298 }
151- window . history . pushState ( { } , '' , url ) ;
299+
300+ const historyMethod = replaceHistory ? 'replaceState' : 'pushState' ;
301+ window . history [ historyMethod ] ( { } , '' , url ) ;
152302 } ;
153303
154304 // Filter out Tesla listings if NO TESLA is enabled
@@ -161,6 +311,18 @@ function App() {
161311 } ) ) ;
162312 } ;
163313
314+ const dateFilteredData = useMemo ( ( ) => {
315+ if ( data . length === 0 || rangeDateLabels . length === 0 ) {
316+ return [ ] ;
317+ }
318+
319+ const allowedDates = new Set ( rangeDateLabels ) ;
320+ return data . filter ( sourceData => {
321+ const dateOnly = sourceData . scraped_at . split ( 'T' ) [ 0 ] ;
322+ return allowedDates . has ( dateOnly ) ;
323+ } ) ;
324+ } , [ data , rangeDateLabels ] ) ;
325+
164326 if ( loading ) {
165327 return < div className = "loading" > Loading price data...</ div > ;
166328 }
@@ -169,7 +331,7 @@ function App() {
169331 return < div className = "error" > Error: { error } </ div > ;
170332 }
171333
172- const filteredData = filterTesla ( data ) ;
334+ const filteredData = filterTesla ( dateFilteredData ) ;
173335 const categoryFilteredData = filterDataByCategory ( filteredData , selectedCategory ) ;
174336
175337 const activeCategory = CATEGORY_TABS . find ( tab => tab . id === selectedCategory ) || CATEGORY_TABS [ 0 ] || null ;
@@ -213,6 +375,11 @@ function App() {
213375 onModelSelect = { handleModelSelect }
214376 onDateSelect = { handleDateSelect }
215377 selectedDate = { selectedDate }
378+ timeRangeId = { timeRangeId }
379+ onTimeRangeChange = { handleTimeRangeChange }
380+ timeRangeOptions = { TIME_RANGE_OPTIONS }
381+ dateLabels = { rangeDateLabels }
382+ availableDates = { availableRangeDates }
216383 />
217384 < NewListings data = { categoryFilteredData } selectedDate = { selectedDate } />
218385 </ >
@@ -228,6 +395,11 @@ function App() {
228395 model = { selectedModel }
229396 onDateSelect = { handleDateSelect }
230397 selectedDate = { selectedDate }
398+ timeRangeId = { timeRangeId }
399+ onTimeRangeChange = { handleTimeRangeChange }
400+ timeRangeOptions = { TIME_RANGE_OPTIONS }
401+ dateLabels = { rangeDateLabels }
402+ availableDates = { availableRangeDates }
231403 />
232404 < ModelListingsView
233405 data = { filteredData }
0 commit comments