Strava API activity sync with Axios, React, Redux & Saga - part 2

Wednesday, December 16, 2020

With the auth and state setup from part 1 of "Strava API activity sync with Axios, React, Redux & Saga" we can now progress to getting the activities and displaying them in a table.

Overview

Now we have tokens we can start to get the data from Strava. The steps we will take cover the following:

  • Adding moment to handle date based checks
  • Create constants for getting activities
  • Create the reducers to handle the results of getting the activities
  • Using Sagas to retrieve the activities from Strava's API, including;
    • Auth - Checking the token is valid and if not getting a new one
    • Activities - Retrieving multiple pages of data
  • Display the activity data on the landing page:
    • Creating a Table component.
    • Using it on the Homepage
  • Bonus: Loading states!

Handling dates

We're going to be doing some date manipulations and checks so let's add a library to help us... Moment for some date "maths".

1yarn add moment 
Note: At this point in time you can, and probably should, use native JavaScript date objects. However, for the sake of simplicity I am using moment.

Create the Activities life cycle

Constants

Firstly we need to create the constants we'll be using to handle the activities. We create one to handle the start of the process, and need a second one which will be used every time we finish loading a page so that we can add the data to our Redux store.

1// file: src/redux/constants/activities.js 
2export const ACTIVITIES_SYNC_START = "ACTIVITIES_SYNC_START";
3export const ACTIVITIES_PAGE_LOADING_COMPLETE = "ACTIVITIES_PAGE_LOADING_COMPLETE"; 

Actions

We only have one action. It will be linked to a button that triggers our syncing process. We import our constant ACTIVITIES_SYNC_START and the use it in the action.

1// file: src/redux/constants/actions/activities.js 
2import { ACTIVITIES_SYNC_START } from "../constants/activities"; 
3
4export const getActivities = () => ({ type: ACTIVITIES_SYNC_START }); 

Reducers

Because the payload we recieve does not include all of the activities in one page we need to merge the existing activity state with the data from each page. We use unionBy to merge the state based on a property of each activity so that we only keep the unique ones and do not end up with duplicates. We use the activity id as this is unique across all Strava activities. One side effect of this is that if you update an activity and resync the new changes will never be updated because the id already exists in our state. Going forward you could write extra actions/reducers/sagas to remedy this.

1import unionBy from "lodash/unionBy"; 
2
3import { ACTIVITIES_PAGE_LOADING_COMPLETE} from "../constants/activities";
4const initialState = { 
5  activities: [] // we start with no activities 
6}; 
7
8export default function (state = initialState, action) { 
9  switch (action.type) { 
10    case ACTIVITIES_PAGE_LOADING_COMPLETE: 
11      // unionBy will merge 2 sets of data by ap property on the objects - our case id 
12      // if we reimport data at a later date then duplicate ids are ignored! 
13      const activities = unionBy(state.activities, action.payload, "id");
14      return { ...state, activities }; 
15      
16      default: 
17      return state; 
18    
19  } 
20}; 

Then update the src/reducers/index.js to import and use the activities reducer we just created:

1import { combineReducers } from "redux"; import { connectRouter } from "connected-react-router"; import auth from "./auth"; import activities from "./activities"; // import this export default (history) => combineReducers({ router: connectRouter(history), auth, activities // and use it! }); 

Reboot your app and you should have an empty redux state for activities:

1activities : { 
2  activities: [] 
3} 

Why do we have activities nested in activities. It's useful to keep the loading state within the outer activities so that we can show loading spinners and display error states. In our tutorial, all we know is that there is a list of activities and have no idea about what currently being updated by the app. This will be covered in a future tutorial.

Updating the token

After a few hours your Strava token will be invalid as it expires. We will use the refresh_token to get a new access token. Starting in validateAuthTokens() we check if the current expires timestamp is still in the future and if it is not, then we know that we need a new set of tokens.

updateRefreshToken calls the token endpoint using our client_id, client_secret, the refresh_token which will return your new tokens. If you hit this endpoint before the tokens expire then it just returns the current ones. Since you have to wait approx 6 hours for tokens to expire you can manually edit the expiresAt in the dev tools > application > localstorage and set it to 0 - This will then think the token has expired!

In the real world™️, the tokenClient should be on a backend (eg Netlify function or AWS lambda) where the client_secret is not accessible to the users browser. The token client would be replaced by a backendClient or secretClient or similar which would be passed only the refreshToken.

1// file: src/redux/sagas/auth.js
2
3//... the existing auth.js js file
4
5const updateRefreshToken = () => {
6    // Get the refresh token from localstorage
7    const refreshToken = localStorage.getItem("refreshToken");
8    
9    // using the token client we then post to the token endpoint with
10    // our client_id, client_secret, the refresh_token
11    // In a realworld app we would pass our refresh_token to a backend where we can keep the client_secret a secret!
12    return tokenClient({
13        url: "/token",
14        method: "post",
15        params: {
16            client_id: clientID,
17            client_secret: clientSecret,
18            refresh_token: refreshToken,
19            grant_type: "refresh_token"
20        }
21    })
22        .then((response) => {
23            // return the new tokens and timestamps to be handled
24            return response.data;
25        })
26        .catch((error) => {
27            throw new Error(error);
28        });
29};
30
31const tokenIsValid = () => {
32    // get the existing token from localstorage
33    const expiresAt = localStorage.getItem("expiresAt");
34    // create a timestamp for now down to the second
35    const now = Math.floor(Date.now() / 1000);
36    // Check if the expires timestamp is less than now - meaning it's in the past and has expired
37    return now < expiresAt;
38};
39
40export function* validateAuthTokens() {
41    // 1. call the function to check if the token is valid
42    const validToken = yield call(tokenIsValid);
43    
44    // 2. If the token is invalid then start the process to get a new one
45    if (!validToken) {
46        // call the function to get new refresh tokens
47        const data= yield call(updateRefreshToken);
48        // put the action to handle the token updates (which stores them in localstorage)
49        yield put(updateAuthTokens(data));
50    }
51}

Activities Saga

Now that we have a way to update the token and keep our client valid we can create our activities saga. Once triggered, this will:

  • Check/update the auth tokens (with the tokenClient we just setup)
  • Starting at an epoch and page 1 it will step through all pages of activities
    • Use the apiClient to retrieve each page until it reaches a page with an empty list, meaning we have reached the end.
1import get from "lodash/get";
2import moment from "moment";
3import { call, put, select, takeLeading } from "redux-saga/effects";
4
5import { apiClient } from "../../api";
6import { validateAuthTokens} from "./auth";
7import {ACTIVITIES_PAGE_LOADING_COMPLETE, ACTIVITIES_SYNC_START} from "../constants/activities";
8
9const getActivity = (epoch, page = 1) => {
10    // Get the data from Strava starting from `epoch` and with the page number of `page`
11    return apiClient({
12        url: `/athlete/activities?per_page=30&after=${epoch}&page=${page}`,
13        method: "get"
14    })
15        .then((response) => {
16            // return the data
17            return response;
18        })
19        .catch((err) => {
20            throw err;
21        });
22};
23
24const getLastActivityTimestamp = (state) => {
25    // The epoch is dependant on the last activity date if we have one
26    // This is so that we only get activities that we don't already have 
27    const lastActivity = state.activities.activities.slice(-1)[0];
28    const lastDate = get(lastActivity, "start_date");
29
30    if (lastDate) {
31        // return the unix timestamp of the last date
32        return moment(lastDate).unix();
33    }
34    
35    // Manually set a year (either in the env or default to 2019)
36    // I have set a recent one otherwize it's a big first sync
37    // And if there are a LOT of activities then you may run out of local storage!
38    const initialYear = Number(process.env.REACT_APP_INITIALYEAR || 2019);
39    
40    // Create a timestamp for this year
41    return moment().year(initialYear).startOf("year").unix();
42};
43
44function* updateAthleteActivity() {
45    // 1. Check the tokens are valid and update as needed
46    yield call(validateAuthTokens);
47    
48    // 2. set the page and epoch - The epoch depends on the last activity date
49    let page = 1;
50    const epoch = yield select(getLastActivityTimestamp);
51
52    try {
53        while (true) {
54            // Loop through pages until we manually break the cycle
55            // Start the process of getting a page from Strava
56            const { data } = yield call(getActivity, epoch, page);
57
58            // Page has no activities - Last page reached
59            if (!data.length) {
60                // If the result has an empty array (no activities) then end the sync - we must have them all!
61                break;
62            } else {
63                // put the data into redux and let the activity reducer merge it
64                yield put({ type: ACTIVITIES_PAGE_LOADING_COMPLETE, payload: data });
65  
66                // Next slide please!
67                page += 1;
68            }
69        }
70    } catch (e) {
71        yield console.log(e)
72    }
73
74}
75
76export function* watchUpdateAthleteActivitiesAsync() {
77    yield takeLeading(ACTIVITIES_SYNC_START, updateAthleteActivity);
78}

Import the new saga to the root saga so we can start to listen for ACTIVITIES_SYNC_START.

1// file src/redux/sagas/index.js
2import { all } from "redux-saga/effects";
3
4import { beginStravaAuthAsync, validateStravaTokenAsync } from "./auth";
5import { watchUpdateAthleteActivitiesAsync} from "./activities"; // Add
6
7// single entry point to start all Sagas at once
8export default function* rootSaga() {
9    yield all([ 
10                beginStravaAuthAsync(), 
11                validateStravaTokenAsync(), 
12                watchUpdateAthleteActivitiesAsync() // Add
13            ]);
14}

Create the "front end"

Display activities in a table

Create a table component. This takes an array of activities and renders the title.

1const Table = ({activities}) => {
2
3    return (
4        <table>
5            <thead>
6                <tr>
7                    <th>Name</th>
8                </tr>
9            </thead>
10            <tbody>
11                {activities.map(activity => (
12                    <tr key={activity.id}>
13                        <td>{activity.name}</td>
14                    </tr>
15                ))}
16            </tbody>
17        </table>
18    )
19}
20
21export default Table;

There is a lot more that can be displayed if you wish!

1{
2    resource_state:2
3    name:"Night Ride"
4    distance:20800.4
5    moving_time:4649
6    elapsed_time:4649
7    total_elevation_gain:96
8    type:"Ride"
9    workout_type:null
10    id:398412246
11    external_id:"Ride390811004.gpx"
12    upload_id:447540868
13    start_date:"2010-01-01T00:00:00Z"
14    start_date_local:"2010-01-01T00:00:00Z"
15    timezone:"(GMT+00:00) Europe/London"
16    utc_offset:0
17    location_city:"Malpas"
18    location_state:null
19    location_country:"United Kingdom"
20    start_latitude:55.06
21    start_longitude:-1.68
22    achievement_count:0
23    kudos_count:0
24    comment_count:0
25    athlete_count:1
26    photo_count:0
27    trainer:false
28    commute:false
29    manual:false
30    private:false
31    visibility:"everyone"
32    flagged:false
33    gear_id:"b1131808"
34    from_accepted_tag:false
35    upload_id_str:"447540868"
36    average_speed:4.474
37    max_speed:5.4
38    average_watts:106.7
39    kilojoules:496
40    device_watts:false
41    has_heartrate:false
42    heartrate_opt_out:false
43    display_hide_heartrate_option:false
44}

The Home page

We need to import our syncActivities action and our Table into the HomePage.

1import { useSelector, useDispatch } from "react-redux";
2
3import {beginStravaAuthentication} from "../redux/actions/auth";
4import {syncActivities} from "../redux/actions/activities"; // New
5import Table from "../components/table"; // New
6
7const HomePage = () => {
8    const auth = useSelector((state) => state.auth);
9    const activities = useSelector((state) => state.activities.activities); // New: Get the activities from the state
10
11    const dispatch = useDispatch();
12
13    return (
14        <div>
15            <h1>Home</h1>
16
17            { auth.isAuthenticated ? (
18                <>
19                    <h2>Logged in</h2>
20                    {/* Add a button to  dispatch our syncActivities action  */}
21                    <button type="button" onClick={() => dispatch(syncActivities())}>Sync activities</button>
22                    
23                    {/* Display the activities in the table  */}
24                    <Table activities={activities} />
25                </>
26            ): (
27                // add the dispatch to the button onClick
28                <button type="button" onClick={() => dispatch(beginStravaAuthentication())}>Authorise on Strava</button>
29            )}
30        </div>
31    )
32}
33
34export default HomePage;

With this added you can now sync your activities.

Table rendered

When you do this you will notice that the list slowly starts to grow as it syncs and the only way you know that it is complete is that it stops growing after a while (and you can see your latest activities!).

Some extra bonuses

Save the activities

Now that we have activities it would be nice if they are saved on a refresh! Open up your configureStore and update the saveState to:

1saveState({
2    auth: store.getState().auth,
3    activities: store.getState().activities // Additional state to save
4});

Show a loading state

To add a loading state to the activities we need to add a loading state. First add a new constant to handle this:

1// file: src/redux/constants/activities.js
2export const ACTIVITIES_SYNC_STATE = "ACTIVITIES_SYNC_STATE";
3// ... the rest
4

Then add an acompanying action. This will take a payload as an argument that we can use to update the state. In our case we will be passing it something like {loading: true}.

1// file: src/redux/actions/activities.js
2export const setActivitiesState = (data) => ({
3    type: ACTIVITIES_SYNC_STATE,
4    payload: data
5});

We now need to update our reducers to handle this new action:

1// file: src/redux/reducers/activities.js
2import unionBy from "lodash/unionBy";
3
4import { ACTIVITIES_PAGE_LOADING_COMPLETE, ACTIVITIES_SYNC_STATE } from "../constants/activities"; // add ACTIVITIES_SYNC_STATE
5
6const initialState = {
7    activities: [],
8    loading: false // add the default loading state (false)
9};
10
11export default function (state = initialState, action) {
12    switch (action.type) {
13        // Add the action here
14        case ACTIVITIES_SYNC_STATE: 
15            // merge our payload in
16            return { ...state, ...action.payload}
17
18        case ACTIVITIES_PAGE_LOADING_COMPLETE:
19            const activities = unionBy(state.activities, action.payload, "id");
20            return { ...state, activities };
21
22        default:
23            return state;
24    }
25};

Our saga must now be updated to put the actions into redux so we can change the loading state.

1// file: src/redux/sagas/activities.js
2// ... other imports
3
4import { setActivitiesState} from "../actions/activities"; // import the state new action
5
6
7// ... the rest
8
9function* updateAthleteActivity() {
10    // Add the put for ACTIVITIES_SYNC_STATE and we pass a payload of loading: true
11    yield put(setActivitiesState({loading: true})) 
12
13    yield call(validateAuthTokens);
14
15    let page = 1;
16    const epoch = yield select(getLastActivityTimestamp);
17    try {
18        while (true) {
19            const { data } = yield call(getActivity, epoch, page);
20
21            // Page has no activities - Last page reached
22            if (!data.length) {
23                break;
24            } else {
25                yield put({ type: ACTIVITIES_PAGE_LOADING_COMPLETE, payload: data });
26                page += 1;
27            }
28        }
29    } catch (e) {
30        yield console.log(e)
31    }
32    
33    // I like to add a small delay to the final action just so that the loading state is visible for a short while
34    // so that users *feel* like something has happened and don't miss it!
35    yield delay(1000)
36    // Add the put for ACTIVITIES_SYNC_STATE and we pass a payload of loading: false
37    // This sets it back to the non loading state and the table should show with everything in!
38    yield put(setActivitiesState({loading: false}))
39}
40
41// ... the rest

On the HomePage update the activities state to it returns the full activity object so we can hook into the loading state:

1 // Old
2    // const activities = useSelector((state) => state.activities.activities);
3
4    // New
5    const activities = useSelector((state) => state.activities);
1    // Old
2    // <Table activities={activities} />
3
4    // New
5    <Table data={activities} />

Finally, update the Table component to take the new data prop, then extract the loading state and activity data, and update the render to display a loading state.

1const Table = ({ data }) => {
2
3    const { loading, activities } = data;
4
5    return (
6        <>
7            {loading ? (
8                <div>
9                    <h3>Loading...</h3>
10                </div>
11            ) : (
12                <table>
13                    <thead>
14                    <tr>
15                        <th>Name</th>
16                    </tr>
17                    </thead>
18                    <tbody>
19                    {activities.map(activity => (
20                        <tr>
21                            <td>{activity.name}</td>
22                        </tr>
23                    ))}
24                    </tbody>
25                </table>
26            )}
27        </>
28    )
29}
30
31export default Table;

Next

And that's it! In the final part of the Strava API app (coming soon) we will move our client_secret secret to a Netlify function to keep things safer.


Other posts


Tagged with: