Skip to content

Commit 90f3cd7

Browse files
progressive loading
1 parent b8fd002 commit 90f3cd7

File tree

7 files changed

+315
-106
lines changed

7 files changed

+315
-106
lines changed

src/App.jsx

Lines changed: 185 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import React, { useState, useEffect, useMemo } from 'react';
2-
import { loadAllData } from './services/dataLoader';
2+
import { loadAllData, getModelKey } from './services/dataLoader';
33
import OverviewChart from './components/OverviewChart';
44
import DetailChart from './components/DetailChart';
55
import ModelListingsView from './components/ModelListingsView';
66
import NewListings from './components/NewListings';
77
import NoTeslaToggle from './components/NoTeslaToggle';
88
import Footer from './components/Footer';
9-
import { CATEGORY_TABS, DEFAULT_CATEGORY, filterDataByCategory } from './utils/modelCategories';
9+
import { CATEGORY_TABS, DEFAULT_CATEGORY, isModelInCategory } from './utils/modelCategories';
1010

1111
const TIME_RANGE_OPTIONS = [
1212
{ id: '7d', label: '7 Days', days: 7 },
@@ -23,13 +23,16 @@ function getTimeRangeOption(rangeId) {
2323

2424
function App() {
2525
const [data, setData] = useState([]);
26-
const [loading, setLoading] = useState(true);
26+
const [dataLoading, setDataLoading] = useState(true);
2727
const [error, setError] = useState(null);
2828
const [selectedModel, setSelectedModel] = useState(null);
2929
const [noTesla, setNoTesla] = useState(false);
3030
const [selectedDate, setSelectedDate] = useState(null);
3131
const [selectedCategory, setSelectedCategory] = useState(DEFAULT_CATEGORY);
3232
const [timeRangeId, setTimeRangeId] = useState(DEFAULT_RANGE_ID);
33+
const [loadedDays, setLoadedDays] = useState(0);
34+
const [categoryDataCache, setCategoryDataCache] = useState({});
35+
const [categoryLoadedDays, setCategoryLoadedDays] = useState({});
3336

3437
const activeRangeOption = useMemo(
3538
() => getTimeRangeOption(timeRangeId),
@@ -107,70 +110,102 @@ function App() {
107110

108111
useEffect(() => {
109112
let isMounted = true;
113+
let urlParamsInitialized = false;
110114

111-
loadAllData(MAX_TIME_RANGE_DAYS)
112-
.then(results => {
113-
if (!isMounted) {
114-
return;
115-
}
115+
// Read URL params first
116+
const url = new URL(window.location);
117+
const rangeParam = url.searchParams.get('range');
118+
const validRangeIds = TIME_RANGE_OPTIONS.map(option => option.id);
119+
const initialRangeId = rangeParam && validRangeIds.includes(rangeParam)
120+
? rangeParam
121+
: DEFAULT_RANGE_ID;
122+
const initialRange = TIME_RANGE_OPTIONS.find(opt => opt.id === initialRangeId);
123+
const daysToLoad = initialRange?.days || 30;
124+
125+
// Get initial category from URL
126+
const categoryParam = url.searchParams.get('category');
127+
const initialCategory = (categoryParam && CATEGORY_TABS.some(tab => tab.id === categoryParam))
128+
? categoryParam
129+
: DEFAULT_CATEGORY;
130+
131+
// Create filter function for the selected category
132+
const categoryFilter = (listing) => {
133+
const modelKey = getModelKey(listing);
134+
return isModelInCategory(modelKey, initialCategory);
135+
};
116136

117-
setData(results);
118-
setLoading(false);
137+
// Load data progressively, starting with what's needed for the current time range
138+
loadAllData(daysToLoad, {
139+
batchSize: 7, // Load 7 days at a time
140+
filterListings: categoryFilter, // Only load listings for selected category
141+
onProgress: (progressData) => {
142+
if (!isMounted) return;
119143

120-
const uniqueDates = results.length > 0
121-
? [...new Set(results.map(d => d.scraped_at.split('T')[0]))].sort().reverse()
122-
: [];
123-
const mostRecentDate = uniqueDates[0] || null;
144+
// Update data as each batch arrives
145+
setData(progressData);
124146

125-
const url = new URL(window.location);
147+
// Initialize URL params only once when we have data
148+
if (!urlParamsInitialized && progressData.length > 0) {
149+
urlParamsInitialized = true;
126150

127-
const modelParam = url.searchParams.get('model');
128-
if (modelParam && modelParam !== 'all') {
129-
setSelectedModel(modelParam);
130-
}
151+
const uniqueDates = [...new Set(progressData.map(d => d.scraped_at.split('T')[0]))].sort().reverse();
152+
const mostRecentDate = uniqueDates[0] || null;
131153

132-
const noTeslaParam = url.searchParams.get('noTesla');
133-
if (noTeslaParam === 'true') {
134-
setNoTesla(true);
135-
}
154+
const modelParam = url.searchParams.get('model');
155+
if (modelParam && modelParam !== 'all') {
156+
setSelectedModel(modelParam);
157+
}
136158

137-
const categoryParam = url.searchParams.get('category');
138-
if (categoryParam && CATEGORY_TABS.some(tab => tab.id === categoryParam)) {
139-
setSelectedCategory(categoryParam);
140-
} else {
141-
setSelectedCategory(DEFAULT_CATEGORY);
142-
}
159+
const noTeslaParam = url.searchParams.get('noTesla');
160+
if (noTeslaParam === 'true') {
161+
setNoTesla(true);
162+
}
143163

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);
164+
setSelectedCategory(initialCategory);
165+
166+
setTimeRangeId(initialRangeId);
167+
168+
if (rangeParam && rangeParam !== initialRangeId) {
169+
const updatedUrl = new URL(window.location);
170+
if (initialRangeId === DEFAULT_RANGE_ID) {
171+
updatedUrl.searchParams.delete('range');
172+
} else {
173+
updatedUrl.searchParams.set('range', initialRangeId);
174+
}
175+
window.history.replaceState({}, '', updatedUrl);
157176
}
158-
window.history.replaceState({}, '', updatedUrl);
159-
}
160177

161-
const dateParam = url.searchParams.get('date');
162-
if (dateParam && uniqueDates.includes(dateParam)) {
163-
setSelectedDate(dateParam);
164-
} else if (mostRecentDate) {
165-
setSelectedDate(mostRecentDate);
178+
const dateParam = url.searchParams.get('date');
179+
if (dateParam && uniqueDates.includes(dateParam)) {
180+
setSelectedDate(dateParam);
181+
} else if (mostRecentDate) {
182+
setSelectedDate(mostRecentDate);
183+
}
166184
}
185+
}
186+
})
187+
.then(results => {
188+
if (!isMounted) return;
189+
190+
setData(results);
191+
setDataLoading(false);
192+
setLoadedDays(daysToLoad);
193+
194+
// Cache the data for this category
195+
setCategoryDataCache(prev => ({
196+
...prev,
197+
[initialCategory]: results
198+
}));
199+
setCategoryLoadedDays(prev => ({
200+
...prev,
201+
[initialCategory]: daysToLoad
202+
}));
167203
})
168204
.catch(err => {
169-
if (!isMounted) {
170-
return;
171-
}
205+
if (!isMounted) return;
206+
172207
setError(err.message);
173-
setLoading(false);
208+
setDataLoading(false);
174209
});
175210

176211
return () => {
@@ -254,6 +289,54 @@ function App() {
254289
url.searchParams.set('category', categoryId);
255290
}
256291
window.history.pushState({}, '', url);
292+
293+
const newRange = TIME_RANGE_OPTIONS.find(opt => opt.id === timeRangeId);
294+
const daysNeeded = newRange?.days || 30;
295+
const cachedData = categoryDataCache[categoryId];
296+
const cachedDays = categoryLoadedDays[categoryId] || 0;
297+
298+
// If we have cached data for this category and it has enough days, use it
299+
if (cachedData && cachedDays >= daysNeeded) {
300+
setData(cachedData);
301+
setLoadedDays(cachedDays);
302+
return;
303+
}
304+
305+
// Otherwise, load data with new category filter
306+
setDataLoading(true);
307+
setData([]);
308+
309+
const categoryFilter = (listing) => {
310+
const modelKey = getModelKey(listing);
311+
return isModelInCategory(modelKey, categoryId);
312+
};
313+
314+
loadAllData(daysNeeded, {
315+
batchSize: 7,
316+
filterListings: categoryFilter,
317+
onProgress: (progressData) => {
318+
setData(progressData);
319+
}
320+
})
321+
.then(results => {
322+
setData(results);
323+
setLoadedDays(daysNeeded);
324+
setDataLoading(false);
325+
326+
// Cache the data for this category
327+
setCategoryDataCache(prev => ({
328+
...prev,
329+
[categoryId]: results
330+
}));
331+
setCategoryLoadedDays(prev => ({
332+
...prev,
333+
[categoryId]: daysNeeded
334+
}));
335+
})
336+
.catch(err => {
337+
setError(err.message);
338+
setDataLoading(false);
339+
});
257340
};
258341

259342
const handleTimeRangeChange = (rangeId, { replaceHistory = false } = {}) => {
@@ -262,6 +345,9 @@ function App() {
262345
return;
263346
}
264347

348+
const newRange = TIME_RANGE_OPTIONS.find(opt => opt.id === rangeId);
349+
const daysNeeded = newRange?.days || 30;
350+
265351
setTimeRangeId(rangeId);
266352

267353
const url = new URL(window.location);
@@ -271,6 +357,43 @@ function App() {
271357
url.searchParams.set('range', rangeId);
272358
}
273359
window.history[replaceHistory ? 'replaceState' : 'pushState']({}, '', url);
360+
361+
// Load additional data if needed
362+
if (daysNeeded > loadedDays) {
363+
setDataLoading(true);
364+
365+
const categoryFilter = (listing) => {
366+
const modelKey = getModelKey(listing);
367+
return isModelInCategory(modelKey, selectedCategory);
368+
};
369+
370+
loadAllData(daysNeeded, {
371+
batchSize: 7,
372+
filterListings: categoryFilter,
373+
onProgress: (progressData) => {
374+
setData(progressData);
375+
}
376+
})
377+
.then(results => {
378+
setData(results);
379+
setLoadedDays(daysNeeded);
380+
setDataLoading(false);
381+
382+
// Update cache for current category
383+
setCategoryDataCache(prev => ({
384+
...prev,
385+
[selectedCategory]: results
386+
}));
387+
setCategoryLoadedDays(prev => ({
388+
...prev,
389+
[selectedCategory]: daysNeeded
390+
}));
391+
})
392+
.catch(err => {
393+
setError(err.message);
394+
setDataLoading(false);
395+
});
396+
}
274397
};
275398

276399
const handleDateSelect = (date, { replaceHistory = false, force = false } = {}) => {
@@ -323,16 +446,8 @@ function App() {
323446
});
324447
}, [data, rangeDateLabels]);
325448

326-
if (loading) {
327-
return <div className="loading">Loading price data...</div>;
328-
}
329-
330-
if (error) {
331-
return <div className="error">Error: {error}</div>;
332-
}
333-
449+
// Data is already filtered by category at load time, just need to filter Tesla
334450
const filteredData = filterTesla(dateFilteredData);
335-
const categoryFilteredData = filterDataByCategory(filteredData, selectedCategory);
336451

337452
const activeCategory = CATEGORY_TABS.find(tab => tab.id === selectedCategory) || CATEGORY_TABS[0] || null;
338453
const categoryDescription = activeCategory?.description ?? '';
@@ -368,10 +483,12 @@ function App() {
368483
)}
369484
</header>
370485
<main className="container">
371-
{!selectedModel ? (
486+
{error ? (
487+
<div className="error">Error: {error}</div>
488+
) : !selectedModel ? (
372489
<>
373490
<OverviewChart
374-
data={categoryFilteredData}
491+
data={filteredData}
375492
onModelSelect={handleModelSelect}
376493
onDateSelect={handleDateSelect}
377494
selectedDate={selectedDate}
@@ -380,8 +497,9 @@ function App() {
380497
timeRangeOptions={TIME_RANGE_OPTIONS}
381498
dateLabels={rangeDateLabels}
382499
availableDates={availableRangeDates}
500+
loading={dataLoading}
383501
/>
384-
<NewListings data={categoryFilteredData} selectedDate={selectedDate} />
502+
<NewListings data={filteredData} selectedDate={selectedDate} loading={dataLoading} />
385503
</>
386504
) : (
387505
<>
@@ -400,11 +518,13 @@ function App() {
400518
timeRangeOptions={TIME_RANGE_OPTIONS}
401519
dateLabels={rangeDateLabels}
402520
availableDates={availableRangeDates}
521+
loading={dataLoading}
403522
/>
404523
<ModelListingsView
405524
data={filteredData}
406525
model={selectedModel}
407526
selectedDate={selectedDate}
527+
loading={dataLoading}
408528
/>
409529
</>
410530
)}

src/components/DetailChart.jsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ export default function DetailChart({
1515
onTimeRangeChange,
1616
timeRangeOptions,
1717
dateLabels,
18-
availableDates
18+
availableDates,
19+
loading = false
1920
}) {
2021
const chartRef = useRef(null);
2122
const chartInstance = useRef(null);
@@ -250,6 +251,14 @@ export default function DetailChart({
250251
options: {
251252
responsive: true,
252253
maintainAspectRatio: false,
254+
animation: false,
255+
transitions: {
256+
active: {
257+
animation: {
258+
duration: 0
259+
}
260+
}
261+
},
253262
layout: {
254263
padding: {
255264
right: 100 // Add space for labels on the right
@@ -439,7 +448,7 @@ export default function DetailChart({
439448
chartInstance.current.destroy();
440449
}
441450
};
442-
}, [data, model, onDateSelect, selectedDate, dotSizeMode, dateLabels, availableDates]);
451+
}, [data, model, onDateSelect, selectedDate, dotSizeMode, dateLabels, availableDates, loading]);
443452

444453
const rangeOptions = Array.isArray(timeRangeOptions) ? timeRangeOptions : [];
445454
const activeRangeId = timeRangeId ?? (rangeOptions[0]?.id ?? null);
@@ -480,6 +489,9 @@ export default function DetailChart({
480489
</div>
481490
</div>
482491
<div className="chart-container">
492+
{loading && (!data || data.length === 0) && (
493+
<div className="chart-loading">Loading price data...</div>
494+
)}
483495
<canvas ref={chartRef}></canvas>
484496
<div ref={dateLabelsContainerRef} className="date-labels"></div>
485497
</div>

0 commit comments

Comments
 (0)