Last active
January 16, 2020 00:17
-
-
Save joshkadis/7651a42fa6094f76cc82ec5f0bbbecf8 to your computer and use it in GitHub Desktop.
Node.js and React samples from The Most Laps (themostlaps.com)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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