Skip to content

Commit

Permalink
Implement infinite scroll in dashboard (#501)
Browse files Browse the repository at this point in the history
* inifinite scroll

* remove time select and add button

* use new endpoint for infinite loading

* fix dongleid change to init routes

* preserve events for route

* fix bugs

* fix time select not display routes events

* add a bottom margin to end of drive message

* use map to store all fetched location and events
  • Loading branch information
yaodingyd authored Jun 18, 2024
1 parent 3f2b512 commit 6129905
Show file tree
Hide file tree
Showing 11 changed files with 234 additions and 144 deletions.
54 changes: 49 additions & 5 deletions src/actions/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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
Expand Down Expand Up @@ -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({
Expand All @@ -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());
};
}

Expand Down Expand Up @@ -156,7 +194,7 @@ export function pushTimelineRange(log_id, start, end, allowPathChange = true) {

updateTimeline(state, dispatch, log_id, start, end, allowPathChange);
};

}


Expand Down Expand Up @@ -390,6 +428,11 @@ export function selectTimeFilter(start, end) {
end,
});

dispatch({
type: Types.ACTION_UPDATE_ROUTE_LIMIT,
limit: undefined,
})

dispatch(checkRoutesData());
};
}
Expand All @@ -409,3 +452,4 @@ export function updateRoute(fullname, route) {
route,
};
}

6 changes: 3 additions & 3 deletions src/actions/startup.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down Expand Up @@ -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()]);
Expand Down
1 change: 1 addition & 0 deletions src/actions/types.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
4 changes: 0 additions & 4 deletions src/components/AppHeader/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => ({
Expand Down Expand Up @@ -111,9 +110,6 @@ const AppHeader = ({
<Typography className={classes.logoText}>connect</Typography>
</a>
</div>
<div className="flex order-4 w-full justify-center sm:order-none sm:w-auto">
{Boolean(!primeNav && !viewingRoute && dongleId) && <TimeFilter />}
</div>
<div className="flex flex-row gap-2">
<Suspense><PWAIcon /></Suspense>
<IconButton
Expand Down
44 changes: 34 additions & 10 deletions src/components/Dashboard/DriveList.jsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import React from 'react';
import { connect } from 'react-redux';
import Obstruction from 'obstruction';
import { withStyles } from '@material-ui/core';
import { withStyles, Typography } from '@material-ui/core';

import { checkRoutesData } from '../../actions';
import { checkRoutesData, checkLastRoutesData } from '../../actions';
import VisibilityHandler from '../VisibilityHandler';

import DriveListEmpty from './DriveListEmpty';
import DriveListItem from './DriveListItem';
import ScrollIntoView from '../ScrollIntoView'

const styles = () => ({
drivesTable: {
Expand All @@ -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 = <DriveListEmpty device={device} routes={routes} />;
} else {
contentStatus = <DriveListEmpty device={device} routes={routes} />;
} else if (routes && routes.length > 5) {
contentStatus = (
<div className={classes.endMessage}>
<Typography>There are no more routes found in selected time range.</Typography>
</div>
);
}

// 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 = (
<div className={`${classes.drives} DriveList`}>
{routes.map((drive) => (
<DriveListItem key={drive.fullname} drive={drive} />
))}
{displayRoutes.map((drive, index) => {
// when the last item is in view, we fetch the next routes
return (index === routesSize - 1 ?
<ScrollIntoView key={drive.fullname} onInView={() => dispatch(checkLastRoutesData())}>
<DriveListItem drive={drive} />
</ScrollIntoView> :
<DriveListItem key={drive.fullname} drive={drive} />)
})}
</div>
);
}
Expand All @@ -46,12 +68,14 @@ const DriveList = (props) => {
<div className={classes.drivesTable}>
<VisibilityHandler onVisible={() => dispatch(checkRoutesData())} minInterval={60} />
{content}
{contentStatus}
</div>
);
};

const stateToProps = Obstruction({
routes: 'routes',
lastRoutes : 'lastRoutes',
device: 'device',
});

Expand Down
32 changes: 29 additions & 3 deletions src/components/DeviceInfo/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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: {
Expand Down Expand Up @@ -43,6 +45,7 @@ const styles = (theme) => ({
color: Colors.grey900,
textTransform: 'none',
minHeight: 'unset',
marginRight: '8px',
'&:hover': {
background: '#ddd',
color: Colors.grey900,
Expand Down Expand Up @@ -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}`,
},
Expand Down Expand Up @@ -192,6 +200,7 @@ class DeviceInfo extends Component {
carHealth: {},
snapshot: {},
windowWidth: window.innerWidth,
isTimeSelectOpen: false,
};

this.snapshotButtonRef = React.createRef();
Expand All @@ -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() {
Expand Down Expand Up @@ -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
}
}
}
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -511,6 +530,12 @@ class DeviceInfo extends Component {
? <CircularProgress size={ 19 } />
: 'take snapshot'}
</Button>
<Button
classes={{ root: `${classes.button} ${classes.actionButtonIcon}` }}
onClick={ this.onOpenTimeSelect }
>
<AccessTime fontSize="inherit"/>
</Button>
<Popper
className={ classes.popover }
open={ Boolean(error) }
Expand All @@ -519,6 +544,7 @@ class DeviceInfo extends Component {
>
<Typography>{ error }</Typography>
</Popper>
<TimeSelect isOpen={isTimeSelectOpen} onClose={this.onCloseTimeSelect}/>
</>
);
}
Expand Down
41 changes: 41 additions & 0 deletions src/components/ScrollIntoView/index.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div ref={elementRef}>
{children}
</div>
);
};

export default ScrollIntoView;
Loading

0 comments on commit 6129905

Please sign in to comment.