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 ( <> - - - - @@ -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 = {