diff --git a/src/actions/index.js b/src/actions/index.js
index da09ecb2..3aa46f5e 100644
--- a/src/actions/index.js
+++ b/src/actions/index.js
@@ -10,6 +10,9 @@ import {hasRoutesData } from '../timeline/segments';
import { getDeviceFromState, deviceVersionAtLeast } from '../utils';
let routesRequest = null;
+let routesRequestPromise = null;
+const LIMIT_INCREMENT = 5
+const FIVE_YEARS = 1000 * 60 * 60 * 24 * 365 * 5;
export function checkRoutesData() {
return (dispatch, getState) => {
@@ -22,18 +25,19 @@ export function checkRoutesData() {
return;
}
if (routesRequest && routesRequest.dongleId === state.dongleId) {
- return;
+ // there is already an pending request
+ return routesRequestPromise;
}
console.debug('We need to update the segment metadata...');
const { dongleId } = state;
const fetchRange = state.filter;
routesRequest = {
- req: Drives.getRoutesSegments(dongleId, fetchRange.start, fetchRange.end),
+ req: Drives.getRoutesSegments(dongleId, fetchRange.start, fetchRange.end, state.limit),
dongleId,
};
- routesRequest.req.then((routesData) => {
+ routesRequestPromise = routesRequest.req.then((routesData) => {
state = getState();
const currentRange = state.filter;
if (currentRange.start !== fetchRange.start
@@ -74,7 +78,7 @@ export function checkRoutesData() {
segment_durations: r.segment_start_times.map((x, i) => r.segment_end_times[i] - x),
};
}).sort((a, b) => {
- return b.create_time - a.create_time;
+ return b.create_time - a.create_time;
});
dispatch({
@@ -86,11 +90,45 @@ export function checkRoutesData() {
});
routesRequest = null;
+
+ return routes
}).catch((err) => {
console.error('Failure fetching routes metadata', err);
Sentry.captureException(err, { fingerprint: 'timeline_fetch_routes' });
routesRequest = null;
});
+
+ return routesRequestPromise
+ };
+}
+
+export function checkLastRoutesData() {
+ return (dispatch, getState) => {
+ const limit = getState().limit
+ const routes = getState().routes
+
+ // if current routes are fewer than limit, that means the last fetch already fetched all the routes
+ if (routes && routes.length < limit) {
+ return
+ }
+
+ console.log(`fetching ${limit +LIMIT_INCREMENT } routes`)
+ dispatch({
+ type: Types.ACTION_UPDATE_ROUTE_LIMIT,
+ limit: limit + LIMIT_INCREMENT,
+ })
+
+ const d = new Date();
+ const end = d.getTime();
+ const start = end - FIVE_YEARS;
+
+ dispatch({
+ type: Types.ACTION_SELECT_TIME_FILTER,
+ start,
+ end,
+ });
+
+ dispatch(checkRoutesData());
};
}
@@ -156,7 +194,7 @@ export function pushTimelineRange(log_id, start, end, allowPathChange = true) {
updateTimeline(state, dispatch, log_id, start, end, allowPathChange);
};
-
+
}
@@ -390,6 +428,11 @@ export function selectTimeFilter(start, end) {
end,
});
+ dispatch({
+ type: Types.ACTION_UPDATE_ROUTE_LIMIT,
+ limit: undefined,
+ })
+
dispatch(checkRoutesData());
};
}
@@ -409,3 +452,4 @@ export function updateRoute(fullname, route) {
route,
};
}
+
diff --git a/src/actions/startup.js b/src/actions/startup.js
index a2bef753..66901080 100644
--- a/src/actions/startup.js
+++ b/src/actions/startup.js
@@ -3,7 +3,7 @@ import { account as Account, devices as Devices } from '@commaai/api';
import MyCommaAuth from '@commaai/my-comma-auth';
import { ACTION_STARTUP_DATA } from './types';
-import { primeFetchSubscription, checkRoutesData, selectDevice, fetchSharedDevice } from '.';
+import { primeFetchSubscription, checkLastRoutesData, selectDevice, fetchSharedDevice } from '.';
async function initProfile() {
if (MyCommaAuth.isAuthenticated()) {
@@ -41,8 +41,8 @@ async function initDevices() {
export default function init() {
return async (dispatch, getState) => {
let state = getState();
- if (state.dongleId) {
- dispatch(checkRoutesData());
+ if (state.dongleId && !state.routes) {
+ dispatch(checkLastRoutesData());
}
const [profile, devices] = await Promise.all([initProfile(), initDevices()]);
diff --git a/src/actions/types.js b/src/actions/types.js
index c0123752..b9dc4aa1 100644
--- a/src/actions/types.js
+++ b/src/actions/types.js
@@ -7,6 +7,7 @@ export const ACTION_STARTUP_DATA = 'ACTION_STARTUP_DATA';
// global state management
export const ACTION_SELECT_DEVICE = 'ACTION_SELECT_DEVICE';
export const ACTION_SELECT_TIME_FILTER = 'ACTION_SELECT_TIME_FILTER';
+export const ACTION_UPDATE_ROUTE_LIMIT = 'ACTION_UPDATE_ROUTE_LIMIT'
export const ACTION_UPDATE_DEVICES = 'ACTION_UPDATE_DEVICES';
export const ACTION_UPDATE_DEVICE = 'ACTION_UPDATE_DEVICE';
export const ACTION_UPDATE_ROUTE = 'ACTION_UPDATE_ROUTE';
diff --git a/src/components/AppHeader/index.jsx b/src/components/AppHeader/index.jsx
index 0d69a735..b7b575f3 100644
--- a/src/components/AppHeader/index.jsx
+++ b/src/components/AppHeader/index.jsx
@@ -13,7 +13,6 @@ import Colors from '../../colors';
import { filterRegularClick } from '../../utils';
import AccountMenu from './AccountMenu';
-import TimeFilter from './TimeFilter';
import PWAIcon from '../PWAIcon';
const styles = () => ({
@@ -111,9 +110,6 @@ const AppHeader = ({
connect
-
- {Boolean(!primeNav && !viewingRoute && dongleId) && }
-
({
drivesTable: {
@@ -19,25 +20,46 @@ const styles = () => ({
padding: 16,
flex: '1',
},
+ endMessage: {
+ padding: 8,
+ textAlign: 'center',
+ marginBottom: 32,
+ },
});
const DriveList = (props) => {
- const { dispatch, classes, device, routes } = props;
-
+ const { dispatch, classes, device, routes, lastRoutes } = props;
+ let contentStatus;
let content;
if (!routes || routes.length === 0) {
- content = ;
- } else {
+ contentStatus = ;
+ } else if (routes && routes.length > 5) {
+ contentStatus = (
+
+ There are no more routes found in selected time range.
+
+ );
+ }
+
+ // we clean up routes during data fetching, fallback to using lastRoutes to display current data
+ const displayRoutes = routes || lastRoutes;
+ if (displayRoutes && displayRoutes.length){
// sort routes by start_time_utc_millis with the latest drive first
// Workaround upstream sorting issue for now
// possibly from https://github.com/commaai/connect/issues/451
- routes.sort((a, b) => b.start_time_utc_millis - a.start_time_utc_millis);
+ displayRoutes.sort((a, b) => b.start_time_utc_millis - a.start_time_utc_millis);
+ const routesSize = displayRoutes.length
content = (
- {routes.map((drive) => (
-
- ))}
+ {displayRoutes.map((drive, index) => {
+ // when the last item is in view, we fetch the next routes
+ return (index === routesSize - 1 ?
+ dispatch(checkLastRoutesData())}>
+
+ :
+ )
+ })}
);
}
@@ -46,12 +68,14 @@ const DriveList = (props) => {
dispatch(checkRoutesData())} minInterval={60} />
{content}
+ {contentStatus}
);
};
const stateToProps = Obstruction({
routes: 'routes',
+ lastRoutes : 'lastRoutes',
device: 'device',
});
diff --git a/src/components/DeviceInfo/index.jsx b/src/components/DeviceInfo/index.jsx
index a7854aea..a74b2acd 100644
--- a/src/components/DeviceInfo/index.jsx
+++ b/src/components/DeviceInfo/index.jsx
@@ -5,6 +5,7 @@ import * as Sentry from '@sentry/react';
import dayjs from 'dayjs';
import { withStyles, Typography, Button, CircularProgress, Popper, Tooltip } from '@material-ui/core';
+import AccessTime from '@material-ui/icons/AccessTime';
import { athena as Athena, devices as Devices } from '@commaai/api';
import { analyticsEvent } from '../../actions';
@@ -13,6 +14,7 @@ import { deviceNamePretty, deviceIsOnline } from '../../utils';
import { isMetric, KM_PER_MI } from '../../utils/conversions';
import ResizeHandler from '../ResizeHandler';
import VisibilityHandler from '../VisibilityHandler';
+import TimeSelect from '../TimeSelect'
const styles = (theme) => ({
container: {
@@ -43,6 +45,7 @@ const styles = (theme) => ({
color: Colors.grey900,
textTransform: 'none',
minHeight: 'unset',
+ marginRight: '8px',
'&:hover': {
background: '#ddd',
color: Colors.grey900,
@@ -108,6 +111,11 @@ const styles = (theme) => ({
padding: '5px 10px',
borderRadius: 15,
},
+ actionButtonIcon: {
+ minWidth: 60,
+ padding: '8px 16px',
+ borderRadius: 15,
+ },
snapshotContainer: {
borderBottom: `1px solid ${Colors.white10}`,
},
@@ -192,6 +200,7 @@ class DeviceInfo extends Component {
carHealth: {},
snapshot: {},
windowWidth: window.innerWidth,
+ isTimeSelectOpen: false,
};
this.snapshotButtonRef = React.createRef();
@@ -205,6 +214,8 @@ class DeviceInfo extends Component {
this.renderButtons = this.renderButtons.bind(this);
this.renderStats = this.renderStats.bind(this);
this.renderSnapshotImage = this.renderSnapshotImage.bind(this);
+ this.onOpenTimeSelect = this.onOpenTimeSelect.bind(this);
+ this.onCloseTimeSelect = this.onCloseTimeSelect.bind(this);
}
componentDidMount() {
@@ -319,8 +330,8 @@ class DeviceInfo extends Component {
if (error.length > 5 && error[5] === '{') {
try {
error = JSON.parse(error.substr(5)).error;
- } catch {
- //pass
+ } catch {
+ //pass
}
}
}
@@ -333,6 +344,14 @@ class DeviceInfo extends Component {
this.setState({ snapshot: { ...snapshot, showFront } });
}
+ onOpenTimeSelect() {
+ this.setState({ isTimeSelectOpen: true });
+ }
+
+ onCloseTimeSelect() {
+ this.setState({ isTimeSelectOpen: false });
+ }
+
render() {
const { classes, device } = this.props;
const { snapshot, deviceStats, windowWidth } = this.state;
@@ -447,7 +466,7 @@ class DeviceInfo extends Component {
renderButtons() {
const { classes, device } = this.props;
- const { snapshot, carHealth, windowWidth } = this.state;
+ const { snapshot, carHealth, windowWidth, isTimeSelectOpen } = this.state;
let batteryVoltage;
let batteryBackground = Colors.grey400;
@@ -511,6 +530,12 @@ class DeviceInfo extends Component {
?
: 'take snapshot'}
+
+
+
{ error }
+
>
);
}
diff --git a/src/components/ScrollIntoView/index.jsx b/src/components/ScrollIntoView/index.jsx
new file mode 100644
index 00000000..dd20fb79
--- /dev/null
+++ b/src/components/ScrollIntoView/index.jsx
@@ -0,0 +1,41 @@
+import React, { useEffect, useRef } from 'react';
+
+const ScrollIntoView = ({ onInView, children }) => {
+ const elementRef = useRef(null);
+ const hasDispatched = useRef(false);
+
+ useEffect(() => {
+ const options = {
+ root: null, // relative to the viewport
+ rootMargin: '0px',
+ threshold: 0.1 // 10% of the target's visibility
+ };
+
+ const observer = new IntersectionObserver((entries) => {
+ entries.forEach(entry => {
+ if (entry.isIntersecting && !hasDispatched.current) {
+ onInView();
+ hasDispatched.current = true
+ }
+ });
+ }, options);
+
+ if (elementRef.current) {
+ observer.observe(elementRef.current);
+ }
+
+ return () => {
+ if (observer && elementRef.current) {
+ observer.unobserve(elementRef.current);
+ }
+ };
+ }, [onInView]);
+
+ return (
+
+ {children}
+
+ );
+};
+
+export default ScrollIntoView;
diff --git a/src/components/AppHeader/TimeFilter.jsx b/src/components/TimeSelect/index.jsx
similarity index 55%
rename from src/components/AppHeader/TimeFilter.jsx
rename to src/components/TimeSelect/index.jsx
index b9a0bc74..8377a43b 100644
--- a/src/components/AppHeader/TimeFilter.jsx
+++ b/src/components/TimeSelect/index.jsx
@@ -2,13 +2,12 @@ import React, { Component } from 'react';
import { connect } from 'react-redux';
import Obstruction from 'obstruction';
import dayjs from 'dayjs';
+import PropTypes from 'prop-types';
-import { Button, Divider, FormControl, MenuItem, Modal, Paper, Select, Typography, withStyles } from '@material-ui/core';
+import { Button, Divider, Modal, Paper, Typography, withStyles } from '@material-ui/core';
import Colors from '../../colors';
import { selectTimeFilter } from '../../actions';
-import { getDefaultFilter } from '../../initialState';
-import VisibilityHandler from '../VisibilityHandler';
const styles = (theme) => ({
modal: {
@@ -26,13 +25,6 @@ const styles = (theme) => ({
marginTop: 20,
textAlign: 'right',
},
- headerDropdown: {
- fontWeight: 500,
- marginRight: 12,
- width: 310,
- maxWidth: '90%',
- textAlign: 'center',
- },
datePickerContainer: {
display: 'flex',
marginBottom: 20,
@@ -61,47 +53,34 @@ class TimeSelect extends Component {
super(props);
this.state = {
- showPicker: false,
- };
+ start: null,
+ end: null,
+ }
- this.handleSelectChange = this.handleSelectChange.bind(this);
this.handleClose = this.handleClose.bind(this);
this.changeStart = this.changeStart.bind(this);
this.changeEnd = this.changeEnd.bind(this);
this.handleSave = this.handleSave.bind(this);
- this.onVisible = this.onVisible.bind(this);
}
- handleSelectChange(e) {
- const selection = e.target.value;
- const d = new Date();
- d.setHours(d.getHours() + 1, 0, 0, 0);
-
- // eslint-disable-next-line default-case
- switch (selection) {
- case '24-hours':
- this.props.dispatch(selectTimeFilter(d.getTime() - (1000 * 60 * 60 * 24), d.getTime()));
- break;
- case '1-week':
- this.props.dispatch(selectTimeFilter(d.getTime() - (1000 * 60 * 60 * 24 * 7), d.getTime()));
- break;
- case '2-weeks':
- this.props.dispatch(selectTimeFilter(d.getTime() - (1000 * 60 * 60 * 24 * 14), d.getTime()));
- break;
- case 'custom':
- this.setState({
- showPicker: true,
- start: this.props.filter.start,
- end: this.props.filter.end,
- });
- break;
+ componentDidMount() {
+ this.setState({
+ start: this.props.filter.start,
+ end: this.props.filter.end,
+ });
+ }
+
+ componentDidUpdate(prevProps) {
+ if (prevProps.filter !== this.props.filter) {
+ this.setState({
+ start: this.props.filter.start,
+ end: this.props.filter.end,
+ });
}
}
handleClose() {
- this.setState({
- showPicker: false,
- });
+ this.props.onClose()
}
changeStart(event) {
@@ -121,57 +100,13 @@ class TimeSelect extends Component {
}
handleSave() {
+ console.log({start: this.state.start, end: this.state.end})
this.props.dispatch(selectTimeFilter(this.state.start, this.state.end));
- this.setState({
- showPicker: false,
- start: null,
- end: null,
- });
- }
-
- selectedOption() {
- const timeRange = this.props.filter.end - this.props.filter.start;
-
- if (Math.abs(this.props.filter.end - Date.now()) < 1000 * 60 * 60) {
- // ends right around now
- if (timeRange === 1000 * 60 * 60 * 24 * 14) {
- return '2-weeks';
- } if (timeRange === 1000 * 60 * 60 * 24 * 7) {
- return '1-week';
- } if (timeRange === 1000 * 60 * 60 * 24) {
- return '24-hours';
- }
- }
-
- return 'custom';
- }
-
- customText() {
- const { filter } = this.props;
- const start = dayjs(filter.start).format('MMM D');
- const end = dayjs(filter.end).format('MMM D');
- let text = `Custom: ${start}`;
- if (start !== end) text += ` - ${end}`;
- return text;
- }
-
- lastWeekText() {
- const weekAgo = dayjs().subtract(1, 'week');
- return `Last week (since ${weekAgo.format('MMM D')})`;
- }
-
- last2WeeksText() {
- const twoWeeksAgo = dayjs().subtract(14, 'day');
- return `Last 2 weeks (since ${twoWeeksAgo.format('MMM D')})`;
- }
-
- onVisible() {
- const filter = getDefaultFilter();
- this.props.dispatch(selectTimeFilter(filter.start, filter.end));
+ this.props.onClose()
}
render() {
- const { classes } = this.props;
+ const { classes, isOpen } = this.props;
const minDate = dayjs().subtract(LOOKBACK_WINDOW_MILLIS, 'millisecond').format('YYYY-MM-DD');
const maxDate = dayjs().format('YYYY-MM-DD');
const startDate = dayjs(this.state.start || this.props.filter.start).format('YYYY-MM-DD');
@@ -179,24 +114,10 @@ class TimeSelect extends Component {
return (
<>
-
-
-
- {this.customText()}
- Last 24 Hours
- {this.lastWeekText()}
- {this.last2WeeksText()}
-
-
@@ -243,4 +164,9 @@ const stateToProps = Obstruction({
filter: 'filter',
});
+TimeSelect.propTypes = {
+ onClose: PropTypes.func.isRequired,
+ isOpen: PropTypes.bool.isRequired,
+};
+
export default connect(stateToProps)(withStyles(styles)(TimeSelect));
diff --git a/src/components/explorer.jsx b/src/components/explorer.jsx
index 5ba74805..9e504733 100644
--- a/src/components/explorer.jsx
+++ b/src/components/explorer.jsx
@@ -15,7 +15,7 @@ import IosPwaPopup from './IosPwaPopup';
import AppDrawer from './AppDrawer';
import PullDownReload from './utils/PullDownReload';
-import { analyticsEvent, selectDevice, updateDevice, selectTimeFilter } from '../actions';
+import { analyticsEvent, selectDevice, updateDevice, checkLastRoutesData } from '../actions';
import init from '../actions/startup';
import Colors from '../colors';
import { play, pause } from '../timeline/playback';
@@ -136,7 +136,7 @@ class ExplorerApp extends Component {
}
componentDidUpdate(prevProps, prevState) {
- const { pathname, zoom, dongleId } = this.props;
+ const { pathname, zoom, dongleId, limit } = this.props;
if (prevProps.pathname !== pathname) {
this.setState({ drawerIsOpen: false });
@@ -149,11 +149,11 @@ class ExplorerApp extends Component {
this.props.dispatch(pause());
}
- // FIXME: ensures demo routes stay visible. can be removed once we're infinite scrolling
- if (prevProps.dongleId !== dongleId) {
- const d = new Date();
- d.setHours(d.getHours() + 1, 0, 0, 0);
- this.props.dispatch(selectTimeFilter(d.getTime() - (1000 * 60 * 60 * 24 * 365), d.getTime()));
+ // this is necessary when user goes to explorer for the first time, dongleId is not populated in state yet
+ // so init() will not successfully fetch routes data
+ // when checkLastRoutesData is called within init(), it would set limit so we don't need to check again
+ if (prevProps.dongleId !== dongleId && limit === 0) {
+ this.props.dispatch(checkLastRoutesData());
}
}
@@ -257,6 +257,7 @@ const stateToProps = Obstruction({
dongleId: 'dongleId',
devices: 'devices',
currentRoute: 'currentRoute',
+ limit: 'limit',
});
export default connect(stateToProps)(withStyles(styles)(ExplorerApp));
diff --git a/src/initialState.js b/src/initialState.js
index b5d00a7d..e0ea9e1a 100644
--- a/src/initialState.js
+++ b/src/initialState.js
@@ -25,6 +25,7 @@ export default {
end: null,
},
currentRoute: null,
+ lastRoutes: null,
profile: null,
devices: null,
@@ -44,4 +45,5 @@ export default {
zoom: null,
loop: null,
segmentRange: getSegmentRange(window.location.pathname),
-};
\ No newline at end of file
+ limit: 0,
+};
diff --git a/src/reducers/globalState.js b/src/reducers/globalState.js
index 0c940321..c09d3bc1 100644
--- a/src/reducers/globalState.js
+++ b/src/reducers/globalState.js
@@ -1,6 +1,9 @@
import * as Types from '../actions/types';
import { emptyDevice } from '../utils';
+const eventsMap = {};
+const locationMap = {};
+
function populateFetchedAt(d) {
return {
...d,
@@ -78,6 +81,7 @@ export default function reducer(_state, action) {
case Types.ACTION_SELECT_TIME_FILTER:
state = {
...state,
+ lastRoutes: state.routes,
filter: {
start: action.start,
end: action.end,
@@ -91,6 +95,12 @@ export default function reducer(_state, action) {
currentRoute: null,
};
break;
+ case Types.ACTION_UPDATE_ROUTE_LIMIT:
+ state = {
+ ...state,
+ limit: action.limit,
+ };
+ break;
case Types.ACTION_UPDATE_DEVICES:
state = {
...state,
@@ -139,13 +149,18 @@ export default function reducer(_state, action) {
case Types.ACTION_UPDATE_ROUTE_EVENTS: {
const firstFrame = action.events.find((ev) => ev.type === 'event' && ev.data.event_type === 'first_road_camera_frame');
const videoStartOffset = firstFrame ? firstFrame.route_offset_millis : null;
+ eventsMap[action.fullname] = {
+ events: action.events,
+ videoStartOffset,
+ }
if (state.routes) {
state.routes = state.routes.map((route) => {
- if (route.fullname === action.fullname) {
+ const ev = eventsMap[route.fullname];
+ if (ev) {
return {
...route,
- events: action.events,
- videoStartOffset,
+ events: ev.events,
+ videoStartOffset: ev.videoStartOffset,
};
}
return route;
@@ -160,13 +175,18 @@ export default function reducer(_state, action) {
}
break;
}
- case Types.ACTION_UPDATE_ROUTE_LOCATION:
+ case Types.ACTION_UPDATE_ROUTE_LOCATION: {
+ locationMap[action.fullname] = {
+ location: action.location,
+ locationKey: action.locationKey,
+ }
if (state.routes) {
state.routes = state.routes.map((route) => {
- if (route.fullname === action.fullname) {
+ const loc = locationMap[route.fullname];
+ if (loc) {
return {
...route,
- [action.locationKey]: action.location,
+ [loc.locationKey]: loc.location,
};
}
return route;
@@ -179,6 +199,7 @@ export default function reducer(_state, action) {
state.currentRoute[action.locationKey] = action.location;
}
break;
+ }
case Types.ACTION_UPDATE_SHARED_DEVICE:
if (action.dongleId === state.dongleId) {
state.device = populateFetchedAt(action.device);
@@ -343,7 +364,15 @@ export default function reducer(_state, action) {
.reduce((obj, id) => { obj[id] = state.filesUploading[id]; return obj; }, {});
break;
case Types.ACTION_ROUTES_METADATA:
- state.routes = action.routes;
+ // merge existing routes' event and location info with new routes
+ state.routes = action.routes.map((route) => {
+ const existingRoute = state.lastRoutes ?
+ state.lastRoutes.find((r) => r.fullname === route.fullname) : {};
+ return {
+ ...existingRoute,
+ ...route,
+ }
+ });
state.routesMeta = {
dongleId: action.dongleId,
start: action.start,
@@ -382,9 +411,9 @@ export default function reducer(_state, action) {
}
}
break;
- case Types.ACTION_UPDATE_SEGMENT_RANGE: {
+ case Types.ACTION_UPDATE_SEGMENT_RANGE: {
- if (!action.log_id) {
+ if (!action.log_id) {
state.segmentRange = null;
}
state.segmentRange = {