Skip to content

Instantly share code, notes, and snippets.

@joshkadis
Last active January 16, 2020 00:17
Show Gist options
  • Save joshkadis/7651a42fa6094f76cc82ec5f0bbbecf8 to your computer and use it in GitHub Desktop.
Save joshkadis/7651a42fa6094f76cc82ec5f0bbbecf8 to your computer and use it in GitHub Desktop.
Node.js and React samples from The Most Laps (themostlaps.com)
const Activity = require('../../../schema/Activity');
const {
activityCouldHaveLaps,
getActivityData,
} = require('../../refreshAthlete/utils');
const {
compileStatsForActivities,
updateAthleteStats,
} = require('../../athleteStats');
const { slackError } = require('../../slackNotification');
const { getTimestampFromString } = require('../../athleteUtils');
/**
* Update athlete's last refreshed to match UTC datetime string
*
* @param {Document} athleteDoc
* @param {String} dateTimeStr ISO-8601 string, presumably UTC
* @returns {Boolean} Success of update
*/
async function updateAthleteLastRefreshed(athleteDoc, dateTimeStr) {
const lastRefreshed = getTimestampFromString(dateTimeStr, { unit: 'seconds' }); // UTC
const result = await athleteDoc.updateOne({
last_refreshed: lastRefreshed,
});
return result && result.nModified;
}
/**
* Create Activity document, validate, and save
*
* @param {Object} activityData Formatted data to create Activity
* @param {Bool} isDryRun If true, will validate without saving
* @returns {Document|false} Saved document or false if error
*/
async function createActivityDocument(activityData, isDryRun = false) {
const activityDoc = new Activity(activityData);
// Mongoose returns error here instead of throwing
const invalid = activityDoc.validateSync();
if (invalid) {
console.warn(`Failed to validate activity ${activityDoc.id}`);
return false;
}
if (isDryRun) {
return activityDoc;
}
try {
await activityDoc.save();
console.log(`Saved activity ${activityDoc.id}`);
return activityDoc;
} catch (err) {
console.log(`Error saving activity ${activityDoc.id}`);
console.log(err);
return false;
}
}
/**
* Ingest an activity after fetching it
* Refactored from utils/refresAthlete/refreshAthleteFromActivity.js for v2
*
* @param {Object} activityData JSON object from Strava API
* @param {Athlete} athleteDoc
* @param {Bool} isDryRun If true, no DB updates
* @returns {Object} result
* @returns {String} result.status Allowed status for QueueActivity document
* @returns {String} result.detail Extra info for QueueActivity document
*/
async function ingestActivityFromStravaData(
rawActivity,
athleteDoc,
isDryRun = false,
) {
/*
Note for dry runs:
Processing won't reach this point unless the QueueActivity has
passed the "same number of segment efforts twice in a row" test
*/
// Check eligibility
if (!activityCouldHaveLaps(rawActivity, true)) {
return {
status: 'dequeued',
detail: 'activityCouldHaveLaps() returned false',
};
}
// Check for laps
const activityData = getActivityData(rawActivity);
if (!activityData.laps) {
// Activity was processed but has no laps
return {
status: 'dequeued',
detail: 'activity does not contain laps',
};
}
/*
Start doing stuff that updates DB
*/
// Save to activities collection
const activityDoc = await createActivityDocument(activityData, isDryRun);
if (!activityDoc) {
console.log('createActivityDocument() failed');
return {
status: 'error',
errorMsg: 'createActivityDocument() failed',
};
}
/*
This is as far as we go with a dry run!
*/
if (isDryRun) {
return {
status: 'dryrun',
detail: `Dry run succeeded with ${activityData.laps} laps`,
};
}
// Get updated stats
// @todo refactor compileSpecialStats() so it
// can dry run without always saving the Activity document
const updatedStats = await compileStatsForActivities(
[activityDoc],
athleteDoc.toJSON().stats,
);
console.log(`Added ${updatedStats.allTime - athleteDoc.get('stats.allTime')} to stats.allTime`);
// @todo Combine updateAthleteStats and updateAthleteLastRefreshed
// as single db write operation
// Update Athlete's stats
try {
await updateAthleteStats(athleteDoc, updatedStats);
} catch (err) {
console.log(`Error with updateAthleteStats() for ${athleteDoc.id} after activity ${rawActivity.id}`);
slackError(90, {
function: 'updateAthleteStats',
athleteId: athleteDoc.id,
activityId: rawActivity.id,
});
return {
status: 'error',
errorMsg: 'updateAthleteStats() failed',
};
}
// Update Athlete's last_refreshed time
let success = true;
try {
// @todo Can this even return something falsy?
const updated = await updateAthleteLastRefreshed(
athleteDoc,
rawActivity.start_date,
);
success = !!updated;
} catch (err) {
success = false;
}
if (!success) {
console.log(`updateAthleteLastRefreshed() failed: athlete ${athleteDoc.id} | activity ${rawActivity.id}`);
return {
status: 'error',
errorMsg: 'updateAthleteLastRefreshed() failed',
};
}
/*
First we created a new Activity with laps
Then we updated Athlete's stats and last_refreshed
We made it!
*/
return {
status: 'ingested',
detail: `${activityData.laps} laps`,
};
}
module.exports = { ingestActivityFromStravaData };
import { Component } from 'react';
import PropTypes from 'prop-types';
import { withRouter } from 'next/router';
// Components
import Layout from '../components/Layout';
import RiderPageHeader from '../components/RiderPageHeader';
import RiderPageWelcome from '../components/RiderPageWelcome';
import RiderPageMessage from '../components/RiderPageMessage';
// Utils
import { APIRequest } from '../utils';
import { defaultLocation } from '../config';
import { routeIsV2 } from '../utils/v2/router';
// Error Layouts
import RiderMessage from '../components/layouts/rider/RiderMessage';
import RiderNotFound from '../components/layouts/rider/RiderNotFound';
// Charts
import AllYears from '../components/charts/AllYears';
import SingleYear from '../components/charts/SingleYear';
const NOT_FETCHED_STATUS = 'notFetched';
const DEFAULT_COMPARE_ATHLETE_STATE = {
hasCompareAthlete: false,
compareAthlete: {},
};
class RiderPage extends Component {
static defaultProps = {
status: NOT_FETCHED_STATUS,
athlete: {},
locations: {},
currentLocation: defaultLocation,
shouldShowWelcome: false,
shouldShowUpdated: false,
isDuplicateSignup: false,
};
static propTypes = {
athlete: PropTypes.object,
locations: PropTypes.object,
pathname: PropTypes.string.isRequired,
query: PropTypes.object.isRequired,
currentLocation: PropTypes.string,
router: PropTypes.object.isRequired,
shouldShowWelcome: PropTypes.bool,
shouldShowUpdated: PropTypes.bool,
isDuplicateSignup: PropTypes.bool,
status: PropTypes.string,
}
state = {
chartRendered: false,
showStatsBy: 'byYear',
showStatsYear: new Date().getFullYear(),
...DEFAULT_COMPARE_ATHLETE_STATE,
}
constructor(props) {
super(props);
const {
currentLocation,
} = props;
this.state = {
...this.state,
currentLocation, // @todo Enable changing location
};
}
static async getInitialProps({ query, req = {} }) {
// Basic props from context
const { athleteId = false, location = defaultLocation } = query;
const defaultInitialProps = {
currentLocation: location,
pathname: req.path || `/rider/${athleteId}`,
query,
shouldShowWelcome: !!query.welcome,
shouldShowUpdated: !!query.updated,
isDuplicateSignup: !!query.ds,
status: NOT_FETCHED_STATUS,
};
if (!athleteId) {
return defaultInitialProps;
}
return APIRequest(`/v2/athletes/${athleteId}`, {}, {}) /* ` */
.then((apiResponse) => {
if (!Array.isArray(apiResponse) || !apiResponse.length) {
return defaultInitialProps;
}
const {
athlete,
status,
stats: { locations },
} = apiResponse[0];
return {
...defaultInitialProps,
athlete,
locations,
status,
};
});
}
navigateToRiderPage = ({ value = '' }) => {
if (value.length) {
this.props.router.push(
`/rider?athleteId=${value}`,
`/rider/${value}`,
);
}
}
renderMessage = (msgName) => <RiderMessage
pathname={this.props.pathname}
query={this.props.query}
athlete={this.props.athlete}
msgName={msgName}
/>;
renderNotFound = () => <RiderNotFound
pathname={this.props.pathname}
query={this.props.query}
/>;
canRenderAthlete = () => this.props.status === 'ready' || this.props.status === 'ingesting';
onChartRendered = () => {
this.setState({ chartRendered: true });
}
onSelectYear = ({ value }) => {
this.setState({
showStatsBy: 'byMonth',
showStatsYear: value,
});
}
/**
* Handle change in search/select field for user to compare to
*/
onChangeSearchUsers = (evt) => {
if (!evt || !evt.value) {
this.setState(DEFAULT_COMPARE_ATHLETE_STATE);
return;
}
if (evt.value === this.state.compareAthlete.id) {
return;
}
APIRequest(`/v2/athletes/${evt.value}`)
.then((apiResponse) => {
if (!Array.isArray(apiResponse) || !apiResponse.length) {
this.setState(DEFAULT_COMPARE_ATHLETE_STATE);
}
this.setState({
hasCompareAthlete: true,
compareAthlete: {
athlete: apiResponse[0].athlete,
stats: apiResponse[0].stats,
},
});
});
}
/**
* Get compare athlete's stats for location and year
*
* @return {Object}
*/
getCompareAthleteStats = () => {
const {
hasCompareAthlete,
compareAthlete,
currentLocation,
showStatsYear,
} = this.state;
if (!hasCompareAthlete) {
return {
compareAthleteByYear: [],
compareAthleteByMonth: [],
};
}
// Default values are empty arrays.
const {
byYear = [],
byMonth = {
[showStatsYear]: [],
},
} = compareAthlete.stats.locations[currentLocation];
return {
compareAthleteByYear: byYear,
compareAthleteByMonth: byMonth[showStatsYear] || [],
};
}
/**
* Increment or decrement current state year
*
* @param {Bool} shouldIncrement `true` to increment, `false` to decrement
*/
updateYear(shouldIncrement) {
if (this.state.showStatsBy !== 'byMonth') {
return;
}
const availableYears = [
...this.props.locations[this.state.currentLocation].availableYears,
];
// Cast current year as int
const showStatsYear = parseInt(this.state.showStatsYear, 10);
const showIdx = availableYears.indexOf(showStatsYear);
const firstYear = [...availableYears].shift();
const lastYear = [...availableYears].pop();
if (shouldIncrement && showStatsYear !== lastYear) {
this.setState({ showStatsYear: availableYears[showIdx + 1] });
} else if (!shouldIncrement && showStatsYear !== firstYear) {
this.setState({ showStatsYear: availableYears[showIdx - 1] });
}
}
render() {
const {
pathname,
query,
status,
athlete,
shouldShowWelcome,
shouldShowUpdated,
isDuplicateSignup,
locations,
currentLocation,
router: routerProp,
} = this.props;
const {
showStatsBy,
showStatsYear,
hasCompareAthlete,
compareAthlete,
chartRendered,
} = this.state;
const {
allTime,
single,
byYear: primaryAthleteByYear,
byMonth: primaryAthleteByMonth,
} = locations[currentLocation];
const compareAthleteMeta = compareAthlete.athlete;
const {
compareAthleteByYear,
compareAthleteByMonth,
} = this.getCompareAthleteStats();
if (!this.canRenderAthlete()) {
return this.renderNotFound();
}
if (status === 'ingesting') {
return this.renderMessage('ingesting');
}
if (!allTime) {
return this.renderMessage('noLaps');
}
return (
<Layout
pathname={pathname}
query={query}
>
{shouldShowWelcome && (
<RiderPageWelcome
allTime={allTime}
firstname={athlete.firstname}
/>
)}
{(shouldShowUpdated || isDuplicateSignup)
&& (
<RiderPageMessage
shouldShowUpdated={shouldShowUpdated}
isDuplicateSignup={isDuplicateSignup}
/>
)
}
<RiderPageHeader
firstname={athlete.firstname}
lastname={athlete.lastname}
img={athlete.profile}
allTime={allTime}
single={single}
/>
{showStatsBy === 'byYear' && (
<AllYears
compareTo={compareAthleteMeta}
compareData={compareAthleteByYear}
hasCompare={hasCompareAthlete}
onClickTick={this.onSelectYear}
onChange={this.onChangeSearchUsers}
onChartRendered={this.onChartRendered}
primaryData={primaryAthleteByYear}
primaryId={parseInt(query.athleteId, 10)}
/>
)}
{showStatsBy === 'byMonth' && (
<SingleYear
year={showStatsYear}
primaryData={primaryAthleteByMonth[showStatsYear]}
primaryId={parseInt(query.athleteId, 10)}
compareTo={compareAthleteMeta}
compareData={compareAthleteByMonth}
hasCompare={hasCompareAthlete}
onChange={this.onChangeSearchUsers}
onChartRendered={this.onChartRendered}
onClickBack={() => this.setState({ showStatsBy: 'byYear' })}
onClickPrevYear={() => this.updateYear(false)}
onClickNextYear={() => this.updateYear(true)}
/>
)}
{chartRendered && (
<div style={{ textAlign: 'right' }}>
<a
className="strava_link"
href={`https://www.strava.com/athletes/${query.athleteId}`} /* ` */
target="_blank"
rel="noopener noreferrer"
>
View on Strava
</a>
</div>
)}
{routeIsV2(routerProp) && (
<div style={{ textAlign: 'right' }}>
<span className="version-link">v2</span>
</div>
)}
</Layout>
);
}
}
export default withRouter(RiderPage);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment