Skip to content

Commit 2c56271

Browse files
Date range selectors, mobile improvements
1 parent e036185 commit 2c56271

15 files changed

+1690
-534
lines changed

src/App.jsx

Lines changed: 199 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useState, useEffect } from 'react';
1+
import React, { useState, useEffect, useMemo } from 'react';
22
import { loadAllData } from './services/dataLoader';
33
import OverviewChart from './components/OverviewChart';
44
import DetailChart from './components/DetailChart';
@@ -8,6 +8,19 @@ import NoTeslaToggle from './components/NoTeslaToggle';
88
import Footer from './components/Footer';
99
import { 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+
1124
function 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

Comments
 (0)