API & Client: refactor (rename mpwo to fittrackee)

This commit is contained in:
Sam
2018-06-07 14:45:43 +02:00
parent 1f36de74ba
commit f65d636f85
81 changed files with 99 additions and 98 deletions

View File

@ -0,0 +1,27 @@
FROM node:9.4.0
MAINTAINER SamR1@users.noreply.github.com
# set working directory
RUN mkdir /usr/src/app
WORKDIR /usr/src/app
# add `/usr/src/app/node_modules/.bin` to $PATH
ENV PATH /usr/src/app/node_modules/.bin:$PATH
# add environment variables
ARG REACT_APP_API_URL
ARG NODE_ENV
ENV NODE_ENV $NODE_ENV
ENV REACT_APP_API_URL $REACT_APP_API_URL
# install and cache app dependencies
COPY package.json /usr/src/app/package.json
RUN yarn install --silent
RUN yarn global add react-scripts
# add app
COPY . /usr/src/app/
# start app
CMD yarn start

View File

@ -0,0 +1,43 @@
import { Selector } from 'testcafe'
import { TEST_URL } from './utils'
const randomstring = require('randomstring')
const username = randomstring.generate(8)
const email = `${username}@test.com`
const password = 'lentghOk'
// eslint-disable-next-line no-undef
fixture('/activities').page(`${TEST_URL}/activities`)
test('standard user should be able to add a workout (w/o gpx)', async t => {
await t
.navigateTo(`${TEST_URL}/register`)
.typeText('input[name="username"]', username)
.typeText('input[name="email"]', email)
.typeText('input[name="password"]', password)
.typeText('input[name="passwordConf"]', password)
.click(Selector('input[type="submit"]'))
await t
.navigateTo(`${TEST_URL}/activities/add`)
.expect(Selector('H1').withText('Dashboard').exists).notOk()
.expect(Selector('H2').withText('Add a workout').exists).ok()
.click(Selector('input[name="withoutGpx"]'))
.click(Selector('select').filter('[name="sport_id"]'))
.click(Selector('option').filter('[value="1"]'))
.typeText('input[name="activity_date"]', '2018-12-20')
.typeText('input[name="activity_time"]', '14:05')
.typeText('input[name="duration"]', '01:00:00')
.typeText('input[name="distance"]', '10')
.click(Selector('input[type="submit"]'))
// pb w/ chromium to check
// await t
// .expect(Selector('H1').withText('Dashboard').exists).notOk()
// .expect(Selector('H1').withText('Activity').exists).ok()
})

View File

@ -0,0 +1,25 @@
import { Selector } from 'testcafe'
import { TEST_URL } from './utils'
// database must be initialiazed
const adminEmail = 'admin@example.com'
const adminPassword = 'mpwoadmin'
// eslint-disable-next-line no-undef
fixture('/admin/sports').page(`${TEST_URL}/admin/sports`)
test('admin should be able to access sports administration page', async t => {
// admin login
await t
.navigateTo(`${TEST_URL}/login`)
.typeText('input[name="email"]', adminEmail)
.typeText('input[name="password"]', adminPassword)
.click(Selector('input[type="submit"]'))
await t
.navigateTo(`${TEST_URL}/admin/sports`)
.expect(Selector('H1').withText('Administration - Sports').exists).ok()
.expect(Selector('TD').withText('Hiking').exists).ok()
})

View File

@ -0,0 +1,50 @@
import { Selector } from 'testcafe'
import { TEST_URL } from './utils'
const randomstring = require('randomstring')
const username = randomstring.generate(8)
const email = `${username}@test.com`
const password = 'lentghOk'
// database must be initialiazed
const adminEmail = 'admin@example.com'
const adminPassword = 'mpwoadmin'
// eslint-disable-next-line no-undef
fixture('/admin').page(`${TEST_URL}/admin`)
test('standard user should not be able to access admin page', async t => {
// register user
await t
.navigateTo(`${TEST_URL}/register`)
.typeText('input[name="username"]', username)
.typeText('input[name="email"]', email)
.typeText('input[name="password"]', password)
.typeText('input[name="passwordConf"]', password)
.click(Selector('input[type="submit"]'))
await t
.navigateTo(`${TEST_URL}/admin`)
.expect(Selector('H1').withText('Access denied').exists).ok()
.expect(Selector('H1').withText('Dashboard').exists).notOk()
})
test('admin should be able to access admin page', async t => {
// admin login
await t
.navigateTo(`${TEST_URL}/login`)
.typeText('input[name="email"]', adminEmail)
.typeText('input[name="password"]', adminPassword)
.click(Selector('input[type="submit"]'))
await t
.navigateTo(`${TEST_URL}/admin`)
.expect(Selector('H1').withText('Access denied').exists).notOk()
.expect(Selector('H1').withText('Administration').exists).ok()
.expect(Selector('.admin-items').withText('Sports').exists).ok()
})

View File

@ -0,0 +1,15 @@
import { Selector } from 'testcafe'
import { TEST_URL } from './utils'
// eslint-disable-next-line no-undef
fixture('/').page(`${TEST_URL}/`)
test('users should be able to view the \'/\' page', async t => {
await t
.navigateTo(TEST_URL)
.expect(Selector('A').withText('Dashboard').exists).ok()
})

View File

@ -0,0 +1,97 @@
import { Selector } from 'testcafe'
import { TEST_URL } from './utils'
const randomstring = require('randomstring')
const username = randomstring.generate(8)
const email = `${username}@test.com`
const password = 'lentghOk'
// eslint-disable-next-line no-undef
fixture('/login').page(`${TEST_URL}/login`)
test('should display the registration form', async t => {
await t
.navigateTo(`${TEST_URL}/login`)
.expect(Selector('H1').withText('Login').exists).ok()
.expect(Selector('form').exists).ok()
.expect(Selector('input[name="username"]').exists).notOk()
.expect(Selector('input[name="email"]').exists).ok()
.expect(Selector('input[name="password"]').exists).ok()
.expect(Selector('input[name="passwordConf"]').exists).notOk()
})
test('should throw an error if the user is not registered', async t => {
// register user with duplicate user name
await t
.navigateTo(`${TEST_URL}/login`)
.typeText('input[name="email"]', email)
.typeText('input[name="password"]', password)
.click(Selector('input[type="submit"]'))
// assert user registration failed
await t
.expect(Selector('H1').withText('Login').exists).ok()
.expect(Selector('code').withText(
'Invalid credentials.').exists).ok()
})
test('should allow a user to login', async t => {
// register user
await t
.navigateTo(`${TEST_URL}/register`)
.typeText('input[name="username"]', username)
.typeText('input[name="email"]', email)
.typeText('input[name="password"]', password)
.typeText('input[name="passwordConf"]', password)
.click(Selector('input[type="submit"]'))
await t
.navigateTo(`${TEST_URL}/logout`)
.navigateTo(`${TEST_URL}/login`)
.expect(Selector('H1').withText('Login').exists).ok()
.typeText('input[name="email"]', email)
.typeText('input[name="password"]', password)
.click(Selector('input[type="submit"]'))
// assert user is redirected to '/'
await t
.expect(Selector('H1').withText('Login').exists).notOk()
})
test('should throw an error if the email is invalid', async t => {
// register user with duplicate user name
await t
.navigateTo(`${TEST_URL}/login`)
.typeText('input[name="email"]', `${email}2`)
.typeText('input[name="password"]', password)
.click(Selector('input[type="submit"]'))
// assert user registration failed
await t
.expect(Selector('H1').withText('Login').exists).ok()
.expect(Selector('code').withText(
'Invalid credentials.').exists).ok()
})
test('should throw an error if the password is invalid', async t => {
// register user with duplicate user name
await t
.navigateTo(`${TEST_URL}/login`)
.typeText('input[name="email"]', email)
.typeText('input[name="password"]', `${password}2`)
.click(Selector('input[type="submit"]'))
// assert user registration failed
await t
.expect(Selector('H1').withText('Login').exists).ok()
.expect(Selector('code').withText(
'Invalid credentials.').exists).ok()
})

View File

@ -0,0 +1,40 @@
import { Selector } from 'testcafe'
import { TEST_URL } from './utils'
const randomstring = require('randomstring')
const username = randomstring.generate(8)
const email = `${username}@test.com`
const password = 'lentghOk'
// eslint-disable-next-line no-undef
fixture('/profile').page(`${TEST_URL}/profile`)
test('should be able to access his profile page', async t => {
// register user
await t
.navigateTo(`${TEST_URL}/register`)
.typeText('input[name="username"]', username)
.typeText('input[name="email"]', email)
.typeText('input[name="password"]', password)
.typeText('input[name="passwordConf"]', password)
.click(Selector('input[type="submit"]'))
await t
.navigateTo(`${TEST_URL}/logout`)
.navigateTo(`${TEST_URL}/login`)
.expect(Selector('H1').withText('Login').exists).ok()
.typeText('input[name="email"]', email)
.typeText('input[name="password"]', password)
.click(Selector('input[type="submit"]'))
await t
.navigateTo(`${TEST_URL}/profile`)
.expect(Selector('H1').withText('Login').exists).notOk()
.expect(Selector('H1').withText('Dashboard').exists).notOk()
.expect(Selector('H1').withText('Profile').exists).ok()
.expect(Selector('.userName').withText(username).exists).ok()
})

View File

@ -0,0 +1,177 @@
import { Selector } from 'testcafe'
import { TEST_URL } from './utils'
const randomstring = require('randomstring')
let username = randomstring.generate(8)
const email = `${username}@test.com`
const password = 'lentghOk'
// eslint-disable-next-line no-undef
fixture('/register').page(`${TEST_URL}/register`)
test('should display the registration form', async t => {
await t
.navigateTo(`${TEST_URL}/register`)
.expect(Selector('H1').withText('Register').exists).ok()
.expect(Selector('form').exists).ok()
.expect(Selector('input[name="username"]').exists).ok()
.expect(Selector('input[name="email"]').exists).ok()
.expect(Selector('input[name="password"]').exists).ok()
.expect(Selector('input[name="passwordConf"]').exists).ok()
})
test('should allow a user to register', async t => {
// register user
await t
.navigateTo(`${TEST_URL}/register`)
.expect(Selector('H1').withText('Register').exists).ok()
.typeText('input[name="username"]', username)
.typeText('input[name="email"]', email)
.typeText('input[name="password"]', password)
.typeText('input[name="passwordConf"]', password)
.click(Selector('input[type="submit"]'))
// assert user is redirected to '/'
await t
.expect(Selector('H1').withText('Register').exists).notOk()
})
test('should throw an error if the username is taken', async t => {
// register user with duplicate user name
await t
.navigateTo(`${TEST_URL}/register`)
.typeText('input[name="username"]', username)
.typeText('input[name="email"]', `${email}2`)
.typeText('input[name="password"]', password)
.typeText('input[name="passwordConf"]', password)
.click(Selector('input[type="submit"]'))
// assert user registration failed
await t
.expect(Selector('H1').withText('Register').exists).ok()
.expect(Selector('code').withText(
'Sorry. That user already exists.').exists).ok()
})
username = randomstring.generate(8)
test('should throw an error if the email is taken', async t => {
// register user with duplicate email
await t
.navigateTo(`${TEST_URL}/register`)
.typeText('input[name="username"]', `${username}2`)
.typeText('input[name="email"]', email)
.typeText('input[name="password"]', password)
.typeText('input[name="passwordConf"]', password)
.click(Selector('input[type="submit"]'))
// assert user registration failed
await t
.expect(Selector('H1').withText('Register').exists).ok()
.expect(Selector('code').withText(
'Sorry. That user already exists.').exists).ok()
})
test('should throw an error if the username is too short', async t => {
const shortUsername = 'a'
// register user with duplicate email
await t
.navigateTo(`${TEST_URL}/register`)
.typeText('input[name="username"]', `${shortUsername}`)
.typeText('input[name="email"]', email)
.typeText('input[name="password"]', password)
.typeText('input[name="passwordConf"]', password)
.click(Selector('input[type="submit"]'))
// assert user registration failed
await t
.expect(Selector('H1').withText('Register').exists).ok()
.expect(Selector('code').withText(
'Username: 3 to 12 characters required.').exists).ok()
})
test('should throw an error if the user is too long', async t => {
const longUsername = randomstring.generate(20)
// register user with duplicate email
await t
.navigateTo(`${TEST_URL}/register`)
.typeText('input[name="username"]', `${longUsername}`)
.typeText('input[name="email"]', email)
.typeText('input[name="password"]', password)
.typeText('input[name="passwordConf"]', password)
.click(Selector('input[type="submit"]'))
// assert user registration failed
await t
.expect(Selector('H1').withText('Register').exists).ok()
.expect(Selector('code').withText(
'Username: 3 to 12 characters required.').exists).ok()
})
test('should throw an error if the email is invalid', async t => {
const invalidEmail = `${username}@test`
// register user with duplicate email
await t
.navigateTo(`${TEST_URL}/register`)
.typeText('input[name="username"]', username)
.typeText('input[name="email"]', invalidEmail)
.typeText('input[name="password"]', password)
.typeText('input[name="passwordConf"]', password)
.click(Selector('input[type="submit"]'))
// assert user registration failed
await t
.expect(Selector('H1').withText('Register').exists).ok()
.expect(Selector('code').withText(
'Valid email must be provided.').exists).ok()
})
test('should throw an error if passwords don\'t match', async t => {
// register user with duplicate email
await t
.navigateTo(`${TEST_URL}/register`)
.typeText('input[name="username"]', username)
.typeText('input[name="email"]', email)
.typeText('input[name="password"]', password)
.typeText('input[name="passwordConf"]', `${password}2`)
.click(Selector('input[type="submit"]'))
// assert user registration failed
await t
.expect(Selector('H1').withText('Register').exists).ok()
.expect(Selector('code').withText(
'Password and password confirmation don\'t match.').exists).ok()
})
test('should throw an error if the password is too short', async t => {
const invalidPassword = '1234567'
// register user with duplicate email
await t
.navigateTo(`${TEST_URL}/register`)
.typeText('input[name="username"]', username)
.typeText('input[name="email"]', email)
.typeText('input[name="password"]', invalidPassword)
.typeText('input[name="passwordConf"]', invalidPassword)
.click(Selector('input[type="submit"]'))
// assert user registration failed
await t
.expect(Selector('H1').withText('Register').exists).ok()
.expect(Selector('code').withText(
'Password: 8 characters required.').exists).ok()
})

View File

@ -0,0 +1 @@
export const TEST_URL = process.env.TEST_URL

View File

@ -0,0 +1,4 @@
{
"name": "fittrackee_client",
"version": "0.1.0"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="theme-color" content="#000000">
<link rel="manifest" href="%PUBLIC_URL%/manifest.json">
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
<link
rel="stylesheet"
href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.1/css/bootstrap.min.css"
integrity="sha384-WskhaSGFgHYWDcbwN70/dfYBj47jz9qbsMId/iRN3ewGhXQFZCSftd1LZCfmhktB"
crossorigin="anonymous">
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/fork-awesome@1.0.11/css/fork-awesome.min.css"
integrity="sha256-MGU/JUq/40CFrfxjXb5pZjpoZmxiP2KuICN5ElLFNd8="
crossorigin="anonymous"
>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/foundation-icons/3.0/foundation-icons.min.css"
>
<link
rel="stylesheet"
href="https://unpkg.com/leaflet@1.3.1/dist/leaflet.css"
integrity="sha512-Rksm5RenBEKSKFjgI3a41vrjkw4EVPlJ3+OiI65vTjIdo9brlAacEuKOiQ5OFh7cOI1bkDwLqdLw3Zg0cRJAAQ=="
crossorigin=""
>
<title>FitTrackee</title>
</head>
<body>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<div id="root"></div>
</body>
</html>

View File

@ -0,0 +1,15 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
}
],
"start_url": "./index.html",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View File

@ -0,0 +1,155 @@
import { parse } from 'date-fns'
import FitTrackeeGenericApi from '../fitTrackeeApi'
import FitTrackeeApi from '../fitTrackeeApi/activities'
import { history } from '../index'
import { formatChartData } from '../utils'
import { setError, setLoading } from './index'
import { loadProfile } from './user'
export const pushActivities = activities => ({
type: 'PUSH_ACTIVITIES',
activities,
})
export const updateCalendar = activities => ({
type: 'UPDATE_CALENDAR',
activities,
})
export const setGpx = gpxContent => ({
type: 'SET_GPX',
gpxContent,
})
export const setChartData = chartData => ({
type: 'SET_CHART_DATA',
chartData,
})
export const addActivity = form => dispatch => FitTrackeeApi
.addActivity(form)
.then(ret => {
if (ret.status === 'created') {
if (ret.data.activities.length === 0) {
dispatch(setError('activities: no correct file'))
} else if (ret.data.activities.length === 1) {
dispatch(loadProfile())
history.push(`/activities/${ret.data.activities[0].id}`)
} else { // ret.data.activities.length > 1
dispatch(loadProfile())
history.push('/')
}
} else {
dispatch(setError(`activities: ${ret.message}`))
}
dispatch(setLoading())
})
.catch(error => dispatch(setError(`activities: ${error}`)))
export const addActivityWithoutGpx = form => dispatch => FitTrackeeApi
.addActivityWithoutGpx(form)
.then(ret => {
if (ret.status === 'created') {
dispatch(loadProfile())
history.push(`/activities/${ret.data.activities[0].id}`)
} else {
dispatch(setError(`activities: ${ret.message}`))
}
})
.catch(error => dispatch(setError(`activities: ${error}`)))
export const getActivityGpx = activityId => dispatch => {
if (activityId) {
return FitTrackeeApi
.getActivityGpx(activityId)
.then(ret => {
if (ret.status === 'success') {
dispatch(setGpx(ret.data.gpx))
} else {
dispatch(setError(`activities: ${ret.message}`))
}
})
.catch(error => dispatch(setError(`activities: ${error}`)))
}
dispatch(setGpx(null))
}
export const getActivityChartData = activityId => dispatch => {
if (activityId) {
return FitTrackeeApi
.getActivityChartData(activityId)
.then(ret => {
if (ret.status === 'success') {
dispatch(setChartData(formatChartData(ret.data.chart_data)))
} else {
dispatch(setError(`activities: ${ret.message}`))
}
})
.catch(error => dispatch(setError(`activities: ${error}`)))
}
dispatch(setChartData(null))
}
export const deleteActivity = id => dispatch => FitTrackeeGenericApi
.deleteData('activities', id)
.then(ret => {
if (ret.status === 204) {
dispatch(loadProfile())
history.push('/')
} else {
dispatch(setError(`activities: ${ret.status}`))
}
})
.catch(error => dispatch(setError(`activities: ${error}`)))
export const editActivity = form => dispatch => FitTrackeeGenericApi
.updateData('activities', form)
.then(ret => {
if (ret.status === 'success') {
dispatch(loadProfile())
history.push(`/activities/${ret.data.activities[0].id}`)
} else {
dispatch(setError(`activities: ${ret.message}`))
}
dispatch(setLoading())
})
.catch(error => dispatch(setError(`activities: ${error}`)))
export const getMoreActivities = page => dispatch => FitTrackeeGenericApi
.getData('activities', { page })
.then(ret => {
if (ret.status === 'success') {
if (ret.data.activities.length > 0) {
dispatch(pushActivities(ret.data.activities))
}
} else {
dispatch(setError(`activities: ${ret.message}`))
}
})
.catch(error => dispatch(setError(`activities: ${error}`)))
export const getMonthActivities = (start, end) => dispatch =>
FitTrackeeGenericApi
.getData('activities', { start, end, order: 'asc', per_page: 100 })
.then(ret => {
if (ret.status === 'success') {
if (ret.data.activities.length > 0) {
for (let i = 0; i < ret.data.activities.length; i++) {
ret.data.activities[i].activity_date = parse(
ret.data.activities[i].activity_date
)
}
dispatch(updateCalendar(ret.data.activities))
}
} else {
dispatch(setError(`activities: ${ret.message}`))
}
})
.catch(error => dispatch(setError(`activities: ${error}`)))

View File

@ -0,0 +1,77 @@
import FitTrackeeApi from '../fitTrackeeApi/index'
import { history } from '../index'
export const setData = (target, data) => ({
type: 'SET_DATA',
data,
target,
})
export const setError = message => ({
type: 'SET_ERROR',
message,
})
export const setLoading = () => ({
type: 'SET_LOADING',
})
export const getData = (target, data) => dispatch => {
if (data && data.id && isNaN(data.id)) {
return dispatch(setError(target, `${target}: Incorrect id`))
}
return FitTrackeeApi
.getData(target, data)
.then(ret => {
if (ret.status === 'success') {
dispatch(setData(target, ret.data))
} else {
dispatch(setError(`${target}: ${ret.message}`))
}
})
.catch(error => dispatch(setError(`${target}: ${error}`)))
}
export const addData = (target, data) => dispatch => FitTrackeeApi
.addData(target, data)
.then(ret => {
if (ret.status === 'created') {
history.push(`/admin/${target}`)
} else {
dispatch(setError(`${target}: ${ret.status}`))
}
})
.catch(error => dispatch(setError(`${target}: ${error}`)))
export const updateData = (target, data) => dispatch => {
if (isNaN(data.id)) {
return dispatch(setError(target, `${target}: Incorrect id`))
}
return FitTrackeeApi
.updateData(target, data)
.then(ret => {
if (ret.status === 'success') {
dispatch(setData(target, ret.data))
} else {
dispatch(setError(`${target}: ${ret.message}`))
}
})
.catch(error => dispatch(setError(`${target}: ${error}`)))
}
export const deleteData = (target, id) => dispatch => {
if (isNaN(id)) {
return dispatch(setError(target, `${target}: Incorrect id`))
}
return FitTrackeeApi
.deleteData(target, id)
.then(ret => {
if (ret.status === 204) {
history.push(`/admin/${target}`)
} else {
dispatch(setError(`${target}: ${ret.message}`))
}
})
.catch(error => dispatch(setError(`${target}: ${error}`)))
}

View File

@ -0,0 +1,13 @@
import FitTrackeeApi from '../fitTrackeeApi/stats'
import { setData, setError } from './index'
export const getStats = (userId, type, data) => dispatch => FitTrackeeApi
.getStats(userId, type, data)
.then(ret => {
if (ret.status === 'success') {
dispatch(setData('statistics', ret.data))
} else {
dispatch(setError(`statistics: ${ret.message}`))
}
})
.catch(error => dispatch(setError(`statistics: ${error}`)))

View File

@ -0,0 +1,182 @@
import FitTrackeeApi from '../fitTrackeeApi/user'
import { history } from '../index'
import { generateIds } from '../utils'
import { getData } from './index'
const AuthError = message => ({ type: 'AUTH_ERROR', message })
const AuthErrors = messages => ({ type: 'AUTH_ERRORS', messages })
const PictureError = message => ({ type: 'PICTURE_ERROR', message })
const ProfileSuccess = message => ({ type: 'PROFILE_SUCCESS', message })
const ProfileError = message => ({ type: 'PROFILE_ERROR', message })
const ProfileUpdateError = message => ({
type: 'PROFILE_UPDATE_ERROR', message
})
const initProfileFormData = user => ({ type: 'INIT_PROFILE_FORM', user })
export const emptyForm = () => ({
type: 'EMPTY_USER_FORMDATA'
})
export const handleFormChange = (target, value) => ({
type: 'UPDATE_USER_FORMDATA',
target,
value,
})
export const updateProfileFormData = (target, value) => ({
type: 'UPDATE_PROFILE_FORMDATA',
target,
value,
})
export const getProfile = () => dispatch => FitTrackeeApi
.getProfile()
.then(ret => {
if (ret.status === 'success') {
dispatch(getData('sports'))
return dispatch(ProfileSuccess(ret))
}
return dispatch(ProfileError(ret.message))
})
.catch(error => {
throw error
})
export const register = formData => dispatch => FitTrackeeApi
.register(
formData.username,
formData.email,
formData.password,
formData.passwordConf)
.then(ret => {
if (ret.status === 'success') {
window.localStorage.setItem('authToken', ret.auth_token)
return dispatch(getProfile())
}
return dispatch(AuthError(ret.message))
})
.catch(error => {
throw error
})
export const login = formData => dispatch => FitTrackeeApi
.login(formData.email, formData.password)
.then(ret => {
if (ret.status === 'success') {
window.localStorage.setItem('authToken', ret.auth_token)
return dispatch(getProfile())
}
return dispatch(AuthError(ret.message))
})
.catch(error => {
throw error
})
export const loadProfile = () => dispatch => {
if (window.localStorage.getItem('authToken')) {
return dispatch(getProfile())
}
return { type: 'LOGOUT' }
}
export const logout = () => ({ type: 'LOGOUT' })
const RegisterFormControl = formData => {
const errMsg = []
if (formData.username.length < 3 || formData.username.length > 12) {
errMsg.push('Username: 3 to 12 characters required.')
}
if (formData.password !== formData.passwordConf) {
errMsg.push('Password and password confirmation don\'t match.')
}
if (formData.password.length < 8) {
errMsg.push('Password: 8 characters required.')
}
return errMsg
}
export const handleUserFormSubmit = (event, formType) => (
dispatch,
getState
) => {
event.preventDefault()
const state = getState()
const { formData } = state.formData
formData.formData = state.formData.formData
if (formType === 'Login') {
return dispatch(login(formData.formData))
}
// formType === 'Register'
const ret = RegisterFormControl(formData.formData)
if (ret.length === 0) {
return dispatch(register(formData.formData))
}
return dispatch(AuthErrors(generateIds(ret)))
}
export const initProfileForm = () => (dispatch, getState) => {
const state = getState()
return dispatch(initProfileFormData(state.user))
}
export const handleProfileFormSubmit = event => (dispatch, getState) => {
event.preventDefault()
const state = getState()
if (!state.formProfile.formProfile.password ===
state.formProfile.formProfile.passwordConf) {
return dispatch(ProfileUpdateError(
'Password and password confirmation don\'t match.'
))
}
return FitTrackeeApi
.updateProfile(state.formProfile.formProfile)
.then(ret => {
if (ret.status === 'success') {
dispatch(getProfile())
return history.push('/profile')
}
dispatch(ProfileUpdateError(ret.message))
})
.catch(error => {
throw error
})
}
export const uploadPicture = event => dispatch => {
event.preventDefault()
const form = new FormData()
form.append('file', event.target.picture.files[0])
event.target.reset()
return FitTrackeeApi
.updatePicture(form)
.then(ret => {
if (ret.status === 'success') {
return dispatch(getProfile())
}
return dispatch(PictureError(ret.message))
})
.catch(error => {
throw error
})
}
export const deletePicture = () => dispatch => FitTrackeeApi
.deletePicture()
.then(ret => {
if (ret.status === 'success') {
return dispatch(getProfile())
}
dispatch(PictureError(ret.message))
})
.catch(error => {
throw error
})

View File

@ -0,0 +1,26 @@
import React from 'react'
import { connect } from 'react-redux'
import ActivityAddOrEdit from './ActivityAddOrEdit'
function ActivityAdd (props) {
const { message, sports } = props
return (
<div>
<ActivityAddOrEdit
activity={null}
message={message}
sports={sports}
/>
</div>
)
}
export default connect(
state => ({
message: state.message,
sports: state.sports.data,
user: state.user,
}),
)(ActivityAdd)

View File

@ -0,0 +1,106 @@
import React from 'react'
import { Helmet } from 'react-helmet'
import { connect } from 'react-redux'
import FormWithGpx from './ActivityForms/FormWithGpx'
import FormWithoutGpx from './ActivityForms/FormWithoutGpx'
class ActivityAddEdit extends React.Component {
constructor(props, context) {
super(props, context)
this.state = {
withGpx: true,
}
}
handleRadioChange (changeEvent) {
this.setState({
withGpx:
changeEvent.target.name === 'withGpx'
? changeEvent.target.value : !changeEvent.target.value
})
}
render() {
const { activity, loading, message, sports } = this.props
const { withGpx } = this.state
return (
<div>
<Helmet>
<title>FitTrackee - {activity
? 'Edit a workout'
: 'Add a workout'}
</title>
</Helmet>
<br /><br />
{message && (
<code>{message}</code>
)}
<div className="container">
<div className="row">
<div className="col-md-2" />
<div className="col-md-8">
<div className="card add-activity">
<h2 className="card-header text-center">
{activity ? 'Edit a workout' : 'Add a workout'}
</h2>
<div className="card-body">
{activity ? (
activity.with_gpx ? (
<FormWithGpx activity={activity} sports={sports} />
) : (
<FormWithoutGpx activity={activity} sports={sports} />
)
) : (
<div>
<form>
<div className="form-group row">
<div className="col">
<label className="radioLabel">
<input
type="radio"
name="withGpx"
disabled={loading}
checked={withGpx}
onChange={event => this.handleRadioChange(event)}
/>
with gpx file
</label>
</div>
<div className="col">
<label className="radioLabel">
<input
type="radio"
name="withoutGpx"
disabled={loading}
checked={!withGpx}
onChange={event => this.handleRadioChange(event)}
/>
without gpx file
</label>
</div>
</div>
</form>
{withGpx ? (
<FormWithGpx sports={sports} />
) : (
<FormWithoutGpx sports={sports} />
)}
</div>
)}
</div>
</div>
</div>
<div className="col-md-2" />
</div>
</div>
</div>
)
}
}
export default connect(
state => ({
loading: state.loading
}),
)(ActivityAddEdit)

View File

@ -0,0 +1,83 @@
import React from 'react'
import { Link } from 'react-router-dom'
import { formatActivityDate } from '../../../utils'
export default function ActivityCardHeader(props) {
const { activity, displayModal, sport, title } = props
const activityDate = activity
? formatActivityDate(activity.activity_date)
: null
return (
<div className="container">
<div className="row">
<div className="col-auto">
{activity.next_activity ? (
<Link
className="unlink"
to={`/activities/${activity.next_activity}`}
>
<i
className="fa fa-chevron-left"
aria-hidden="true"
/>
</Link>
) : (
<i
className="fa fa-chevron-left inactive-link"
aria-hidden="true"
/>
)}
</div>
<div className="col-auto col-activity-logo">
<img
className="sport-img-medium"
src={sport.img}
alt="sport logo"
/>
</div>
<div className="col">
{title}{' '}
<Link
className="unlink"
to={`/activities/${activity.id}/edit`}
>
<i
className="fa fa-edit custom-fa"
aria-hidden="true"
/>
</Link>
<i
className="fa fa-trash custom-fa"
aria-hidden="true"
onClick={() => displayModal(true)}
/><br />
{activityDate && (
<span className="activity-date">
{`${activityDate.activity_date} - ${activityDate.activity_time}`}
</span>
)}
</div>
<div className="col-auto">
{activity.previous_activity ? (
<Link
className="unlink"
to={`/activities/${activity.previous_activity}`}
>
<i
className="fa fa-chevron-right"
aria-hidden="true"
/>
</Link>
) : (
<i
className="fa fa-chevron-right inactive-link"
aria-hidden="true"
/>
)}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,196 @@
import { format } from 'date-fns'
import React from 'react'
import { connect } from 'react-redux'
import {
Area, ComposedChart, Line, ResponsiveContainer, Tooltip, XAxis, YAxis
} from 'recharts'
import { getActivityChartData } from '../../../actions/activities'
class ActivityCharts extends React.Component {
constructor(props, context) {
super(props, context)
this.state = {
displayDistance: true,
dataToHide: []
}
}
componentDidMount() {
this.props.loadActivityData(this.props.activity.id)
}
componentDidUpdate(prevProps) {
if (prevProps.activity.id !==
this.props.activity.id) {
this.props.loadActivityData(this.props.activity.id)
}
}
componentWillUnmount() {
this.props.loadActivityData(null)
}
handleRadioChange (changeEvent) {
this.setState({
displayDistance:
changeEvent.target.name === 'distance'
? changeEvent.target.value
: !changeEvent.target.value
})
}
handleLegendChange (e) {
const { dataToHide } = this.state
const name = e.target.name // eslint-disable-line prefer-destructuring
if (dataToHide.find(d => d === name)) {
dataToHide.splice(dataToHide.indexOf(name), 1)
} else {
dataToHide.push(name)
}
this.setState({ dataToHide })
}
displayData (name) {
const { dataToHide } = this.state
return !dataToHide.find(d => d === name)
}
render() {
const { chartData } = this.props
const { displayDistance } = this.state
const xInterval = chartData ? parseInt(chartData.length / 10, 10) : 0
let xDataKey, xScale
if (displayDistance) {
xDataKey = 'distance'
xScale = 'linear'
} else {
xDataKey = 'duration'
xScale = 'time'
}
return (
<div className="container">
{chartData && chartData.length > 0 ? (
<div>
<div className="row chart-radio">
<label className="radioLabel col-md-1">
<input
type="radio"
name="distance"
checked={displayDistance}
onChange={e => this.handleRadioChange(e)}
/>
distance
</label>
<label className="radioLabel col-md-1">
<input
type="radio"
name="duration"
checked={!displayDistance}
onChange={e => this.handleRadioChange(e)}
/>
duration
</label>
</div>
<div className="row chart-radio">
<div className="col-md-5" />
<label className="radioLabel col-md-1">
<input
type="checkbox"
name="speed"
checked={this.displayData('speed')}
onChange={e => this.handleLegendChange(e)}
/>
speed
</label>
<label className="radioLabel col-md-1">
<input
type="checkbox"
name="elevation"
checked={this.displayData('elevation')}
onChange={e => this.handleLegendChange(e)}
/>
elevation
</label>
<div className="col-md-5" />
</div>
<div className="row chart">
<ResponsiveContainer height={300}>
<ComposedChart
data={chartData}
margin={{ top: 15, right: 30, left: 20, bottom: 15 }}
>
<XAxis
allowDecimals={false}
dataKey={xDataKey}
label={{ value: xDataKey, offset: 0, position: 'bottom' }}
scale={xScale}
interval={xInterval}
tickFormatter={value => displayDistance
? value
: format(value, 'HH:mm:ss')}
type="number"
/>
<YAxis
label={{
value: 'speed (km/h)', angle: -90, position: 'left'
}}
yAxisId="left"
/>
<YAxis
label={{
value: 'altitude (m)', angle: -90, position: 'right'
}}
yAxisId="right" orientation="right"
/>
{this.displayData('elevation') && (
<Area
yAxisId="right"
type="linear"
dataKey="elevation"
fill="#e5e5e5"
stroke="#cccccc"
dot={false}
/>
)}
{this.displayData('speed') && (
<Line
yAxisId="left"
type="linear"
dataKey="speed"
stroke="#8884d8"
strokeWidth={2}
dot={false}
/>
)}
<Tooltip
labelFormatter={value => displayDistance
? `distance: ${value} km`
: `duration: ${format(value, 'HH:mm:ss')}`}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
<div className="chart-info">
data from gpx, without any cleaning
</div>
</div>
) : (
'No data to display'
)}
</div>
)
}
}
export default connect(
state => ({
chartData: state.chartData
}),
dispatch => ({
loadActivityData: activityId => {
dispatch(getActivityChartData(activityId))
},
})
)(ActivityCharts)

View File

@ -0,0 +1,93 @@
import React from 'react'
export default function ActivityDetails(props) {
const { activity } = props
const withPauses = activity.pauses !== '0:00:00' && activity.pauses !== null
const recordLDexists = activity.records.find(r => r.record_type === 'LD')
return (
<div>
<p>
<i
className="fa fa-clock-o custom-fa"
aria-hidden="true"
/>
Duration: {activity.duration}
{withPauses && (
<span>
{' '}
(pauses: {activity.pauses})
<br />
Moving duration: {activity.moving}
</span>
)}
{recordLDexists && (
<sup>
<i
className="fa fa-trophy custom-fa"
aria-hidden="true"
/>
</sup>
)}
</p>
<p>
<i
className="fa fa-road custom-fa"
aria-hidden="true"
/>
Distance: {activity.distance} km
{activity.records.find(r => r.record_type === 'FD'
) && (
<sup>
<i
className="fa fa-trophy custom-fa"
aria-hidden="true"
/>
</sup>
)}
</p>
<p>
<i
className="fa fa-tachometer custom-fa"
aria-hidden="true"
/>
Average speed: {activity.ave_speed} km/h
{activity.records.find(r => r.record_type === 'AS'
) && (
<sup>
<i
className="fa fa-trophy custom-fa"
aria-hidden="true"
/>
</sup>
)}
<br />
Max speed : {activity.max_speed} km/h
{activity.records.find(r => r.record_type === 'MS'
) && (
<sup>
<i
className="fa fa-trophy custom-fa"
aria-hidden="true"
/>
</sup>
)}
</p>
{activity.min_alt && activity.max_alt && (
<p>
<i className="fi-mountains custom-fa" />
Min altitude: {activity.min_alt}m
<br />
Max altitude: {activity.max_alt}m
</p>
)}
{activity.ascent && activity.descent && (
<p>
<i className="fa fa-location-arrow custom-fa" />
Ascent: {activity.ascent}m
<br />
Descent: {activity.descent}m
</p>
)}
</div>
)
}

View File

@ -0,0 +1,77 @@
import hash from 'object-hash'
import React from 'react'
import { GeoJSON, Map, TileLayer } from 'react-leaflet'
import { connect } from 'react-redux'
import { getActivityGpx } from '../../../actions/activities'
import { getGeoJson, thunderforestApiKey } from '../../../utils'
class ActivityMap extends React.Component {
constructor(props, context) {
super(props, context)
this.state = {
zoom: 13,
}
}
componentDidMount() {
this.props.loadActivityGpx(this.props.activity.id)
}
componentDidUpdate(prevProps) {
if (prevProps.activity.id !==
this.props.activity.id) {
this.props.loadActivityGpx(this.props.activity.id)
}
}
componentWillUnmount() {
this.props.loadActivityGpx(null)
}
render() {
const { activity, gpxContent } = this.props
const { jsonData } = getGeoJson(gpxContent)
const bounds = [
[activity.bounds[0], activity.bounds[1]],
[activity.bounds[2], activity.bounds[3]]
]
return (
<div>
{jsonData && (
<Map
zoom={this.state.zoom}
bounds={bounds}
boundsOptions={{ padding: [10, 10] }}
>
<TileLayer
// eslint-disable-next-line max-len
attribution='&copy; <a href="http://www.thunderforest.com/">Thunderforest</a>, &copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
// eslint-disable-next-line max-len
url={`https://{s}.tile.thunderforest.com/outdoors/{z}/{x}/{y}.png?apikey=${thunderforestApiKey}`}
/>
<GeoJSON
// hash as a key to force re-rendering
key={hash(jsonData)}
data={jsonData}
/>
</Map>
)}
</div>
)
}
}
export default connect(
state => ({
gpxContent: state.gpx
}),
dispatch => ({
loadActivityGpx: activityId => {
dispatch(getActivityGpx(activityId))
},
})
)(ActivityMap)

View File

@ -0,0 +1,132 @@
import React from 'react'
import { Helmet } from 'react-helmet'
import { connect } from 'react-redux'
import ActivityCardHeader from './ActivityCardHeader'
import ActivityCharts from './ActivityCharts'
import ActivityDetails from './ActivityDetails'
import ActivityMap from './ActivityMap'
import CustomModal from './../../Others/CustomModal'
import { getData } from '../../../actions'
import { deleteActivity } from '../../../actions/activities'
class ActivityDisplay extends React.Component {
constructor(props, context) {
super(props, context)
this.state = {
displayModal: false,
}
}
componentDidMount() {
this.props.loadActivity(this.props.match.params.activityId)
}
componentDidUpdate(prevProps) {
if (prevProps.match.params.activityId !==
this.props.match.params.activityId) {
this.props.loadActivity(this.props.match.params.activityId)
}
}
displayModal(value) {
this.setState({ displayModal: value })
}
render() {
const { activities, message, onDeleteActivity, sports } = this.props
const { displayModal } = this.state
const [activity] = activities
const title = activity ? activity.title : 'Activity'
const [sport] = activity
? sports.filter(s => s.id === activity.sport_id)
: []
return (
<div className="activity-page">
<Helmet>
<title>FitTrackee - {title}</title>
</Helmet>
{message ? (
<code>{message}</code>
) : (
<div className="container">
{displayModal &&
<CustomModal
title="Confirmation"
text="Are you sure you want to delete this activity?"
confirm={() => {
onDeleteActivity(activity.id)
this.displayModal(false)
}}
close={() => this.displayModal(false)}
/>}
{activity && sport && activities.length === 1 && (
<div>
<div className="row">
<div className="col">
<div className="card">
<div className="card-header">
<ActivityCardHeader
activity={activity}
sport={sport}
title={title}
displayModal={() => this.displayModal(true)}
/>
</div>
<div className="card-body">
<div className="row">
{activity.with_gpx && (
<div className="col-8">
<ActivityMap activity={activity} />
</div>
)}
<div className="col">
<ActivityDetails activity={activity} />
</div>
</div>
</div>
</div>
</div>
</div>
{activity.with_gpx && (
<div className="row">
<div className="col">
<div className="card">
<div className="card-body">
<div className="row">
<div className="col">
<div className="chart-title">Chart</div>
<ActivityCharts activity={activity} />
</div>
</div>
</div>
</div>
</div>
</div>
)}
</div>
)}
</div>
)}
</div>
)
}
}
export default connect(
state => ({
activities: state.activities.data,
message: state.message,
sports: state.sports.data,
user: state.user,
}),
dispatch => ({
loadActivity: activityId => {
dispatch(getData('activities', { id: activityId }))
},
onDeleteActivity: activityId => {
dispatch(deleteActivity(activityId))
},
})
)(ActivityDisplay)

View File

@ -0,0 +1,44 @@
import React from 'react'
import { connect } from 'react-redux'
import ActivityAddOrEdit from './ActivityAddOrEdit'
import { getData } from '../../actions'
class ActivityEdit extends React.Component {
componentDidMount() {
this.props.loadActivity(
this.props.match.params.activityId
)
}
render() {
const { activities, message, sports } = this.props
const [activity] = activities
return (
<div>
{sports.length > 0 && (
<ActivityAddOrEdit
activity={activity}
message={message}
sports={sports}
/>
)}
</div>
)
}
}
export default connect(
state => ({
activities: state.activities.data,
message: state.message,
sports: state.sports.data,
user: state.user,
}),
dispatch => ({
loadActivity: activityId => {
dispatch(getData('activities', { id: activityId }))
},
})
)(ActivityEdit)

View File

@ -0,0 +1,116 @@
import React from 'react'
import { connect } from 'react-redux'
import { setLoading } from '../../../actions/index'
import { addActivity, editActivity } from '../../../actions/activities'
import { history } from '../../../index'
function FormWithGpx (props) {
const {
activity, loading, onAddActivity, onEditActivity, sports
} = props
const sportId = activity ? activity.sport_id : ''
return (
<form
encType="multipart/form-data"
method="post"
onSubmit={event => event.preventDefault()}
>
<div className="form-group">
<label>
Sport:
<select
className="form-control input-lg"
defaultValue={sportId}
disabled={loading}
name="sport"
required
>
<option value="" />
{sports.map(sport => (
<option key={sport.id} value={sport.id}>
{sport.label}
</option>
))}
</select>
</label>
</div>
{activity ? (
<div className="form-group">
<label>
Title:
<input
name="title"
defaultValue={activity ? activity.title : ''}
disabled={loading}
className="form-control input-lg"
/>
</label>
</div>
) : (
<div className="form-group">
<label>
<strong>gpx</strong> file or <strong>zip</strong>{' '}
file containing <strong>gpx</strong> (no folder inside):
<input
accept=".gpx, .zip"
className="form-control input-lg"
disabled={loading}
name="gpxFile"
required
type="file"
/>
</label>
</div>
)}
{loading ? (
<div className="loader" />
) : (
<div>
<input
type="submit"
className="btn btn-primary btn-lg btn-block"
onClick={
event => activity
? onEditActivity(event, activity)
: onAddActivity(event)
}
value="Submit"
/>
<input
type="submit"
className="btn btn-secondary btn-lg btn-block"
onClick={() => history.go(-1)}
value="Cancel"
/>
</div>
)}
</form>
)
}
export default connect(
state => ({
loading: state.loading
}),
dispatch => ({
onAddActivity: e => {
dispatch(setLoading())
const form = new FormData()
form.append('file', e.target.form.gpxFile.files[0])
form.append(
'data', `{"sport_id": ${e.target.form.sport.value}}`
)
dispatch(addActivity(form))
},
onEditActivity: (e, activity) => {
dispatch(setLoading())
dispatch(editActivity({
id: activity.id,
sport_id: +e.target.form.sport.value,
title: e.target.form.title.value,
}))
},
})
)(FormWithGpx)

View File

@ -0,0 +1,144 @@
import React from 'react'
import { connect } from 'react-redux'
import {
addActivityWithoutGpx, editActivity
} from '../../../actions/activities'
import { history } from '../../../index'
import { formatActivityDate } from '../../../utils'
function FormWithoutGpx (props) {
const { activity, onAddOrEdit, sports } = props
let activityDate, activityTime, sportId = ''
if (activity) {
const activityDateTime = formatActivityDate(activity.activity_date)
activityDate = activityDateTime.activity_date
activityTime = activityDateTime.activity_time
sportId = activity.sport_id
}
return (
<form
onSubmit={event => event.preventDefault()}
>
<div className="form-group">
<label>
Title:
<input
name="title"
defaultValue={activity ? activity.title : ''}
className="form-control input-lg"
/>
</label>
</div>
<div className="form-group">
<label>
Sport:
<select
className="form-control input-lg"
defaultValue={sportId}
name="sport_id"
required
>
<option value="" />
{sports.map(sport => (
<option key={sport.id} value={sport.id}>
{sport.label}
</option>
))}
</select>
</label>
</div>
<div className="form-group">
<label>
Activity Date:
<div className="container">
<div className="row">
<input
name="activity_date"
defaultValue={activityDate}
className="form-control col-md"
required
type="date"
/>
<input
name="activity_time"
defaultValue={activityTime}
className="form-control col-md"
required
type="time"
/>
</div>
</div>
</label>
</div>
<div className="form-group">
<label>
Duration:
<input
name="duration"
defaultValue={activity ? activity.duration : ''}
className="form-control col-xs-4"
pattern="^([0-9]*[0-9]):([0-5][0-9]):([0-5][0-9])$"
placeholder="hh:mm:ss"
required
type="text"
/>
</label>
</div>
<div className="form-group">
<label>
Distance (km):
<input
name="distance"
defaultValue={activity ? activity.distance : ''}
className="form-control input-lg"
min={0}
required
step="0.001"
type="number"
/>
</label>
</div>
<input
type="submit"
className="btn btn-primary btn-lg btn-block"
onClick={event => onAddOrEdit(event, activity)}
value="Submit"
/>
<input
type="submit"
className="btn btn-secondary btn-lg btn-block"
onClick={() => history.go(-1)}
value="Cancel"
/>
</form>
)
}
export default connect(
() => ({ }),
dispatch => ({
onAddOrEdit: (e, activity) => {
const d = e.target.form.duration.value.split(':')
const duration = +d[0] * 60 * 60 + +d[1] * 60 + +d[2]
const activityDate = `${e.target.form.activity_date.value
} ${ e.target.form.activity_time.value}`
const data = {
activity_date: activityDate,
distance: +e.target.form.distance.value,
duration,
sport_id: +e.target.form.sport_id.value,
title: e.target.form.title.value,
}
if (activity) {
data.id = activity.id
dispatch(editActivity(data))
} else {
dispatch(addActivityWithoutGpx(data))
}
},
})
)(FormWithoutGpx)

View File

@ -0,0 +1,40 @@
import React from 'react'
import { Helmet } from 'react-helmet'
import { connect } from 'react-redux'
import { Redirect, Route, Switch } from 'react-router-dom'
import ActivityAdd from './ActivityAdd'
import ActivityDisplay from './ActivityDisplay'
import ActivityEdit from './ActivityEdit'
import NotFound from './../Others/NotFound'
import { isLoggedIn } from '../../utils'
function Activity () {
return (
<div>
<Helmet>
<title>FitTrackee - Admin</title>
</Helmet>
{isLoggedIn() ? (
<Switch>
<Route exact path="/activities/add" component={ActivityAdd} />
<Route
exact path="/activities/:activityId"
component={ActivityDisplay}
/>
<Route
exact path="/activities/:activityId/edit"
component={ActivityEdit}
/>
<Route component={NotFound} />
</Switch>
) : (<Redirect to="/login" />)}
</div>
)
}
export default connect(
state => ({
user: state.user,
})
)(Activity)

View File

@ -0,0 +1,35 @@
import React from 'react'
import { Helmet } from 'react-helmet'
import { Link } from 'react-router-dom'
export default function AdminMenu () {
return (
<div>
<Helmet>
<title>FitTrackee - Admin - Sports</title>
</Helmet>
<h1 className="page-title">Administration</h1>
<div className="container">
<div className="row">
<div className="col-md-2" />
<div className="col-md-8 card">
<div className="card-body">
<ul className="admin-items">
<li>
<Link
to={{
pathname: '/admin/sports',
}}
>
Sports
</Link>
</li>
</ul>
</div>
</div>
<div className="col-md-2" />
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,39 @@
import React from 'react'
import { connect } from 'react-redux'
import { getData } from '../../../actions'
import AdminDetail from '../generic/AdminDetail'
class AdminSports extends React.Component {
componentDidMount() {
this.props.loadSport(this.props.match.params.sportId)
}
componentWillUnmount() {
// reload all Sports
this.props.loadSport(null)
}
render() {
const { sports } = this.props
return (
<div>
<AdminDetail
results={sports}
target="sports"
/>
</div>
)
}
}
export default connect(
state => ({
sports: state.sports.data,
user: state.user,
}),
dispatch => ({
loadSport: sportId => {
dispatch(getData('sports', { id: sportId }))
},
})
)(AdminSports)

View File

@ -0,0 +1,35 @@
import React from 'react'
import { connect } from 'react-redux'
import { getData } from '../../../actions'
import AdminPage from '../generic/AdminPage'
class AdminSports extends React.Component {
componentDidMount() {
this.props.loadSports()
}
render() {
const { sports } = this.props
return (
<div>
<AdminPage
data={sports}
target="sports"
/>
</div>
)
}
}
export default connect(
state => ({
sports: state.sports,
user: state.user,
}),
dispatch => ({
loadSports: () => {
dispatch(getData('sports'))
},
})
)(AdminSports)

View File

@ -0,0 +1,83 @@
import React from 'react'
import { Helmet } from 'react-helmet'
import { connect } from 'react-redux'
import { addData } from '../../../actions/index'
import { history } from '../../../index'
class AdminSportsAdd extends React.Component {
componentDidMount() { }
render() {
const { message, onAddSport } = this.props
return (
<div>
<Helmet>
<title>FitTrackee - Admin - Add Sport</title>
</Helmet>
<h1 className="page-title">
Administration - Sport
</h1>
{message && (
<code>{message}</code>
)}
<div className="container">
<div className="row">
<div className="col-md-2" />
<div className="col-md-8">
<div className="card">
<div className="card-header">
Add a sport
</div>
<div className="card-body">
<form onSubmit={event =>
event.preventDefault()}
>
<div className="form-group">
<label>
Label:
<input
name="label"
className="form-control input-lg"
type="text"
/>
</label>
</div>
<input
type="submit"
className="btn btn-primary btn-lg btn-block"
onClick={event => onAddSport(event)}
value="Submit"
/>
<input
type="submit"
className="btn btn-secondary btn-lg btn-block"
onClick={() => history.push('/admin/sports')}
value="Cancel"
/>
</form>
</div>
</div>
</div>
<div className="col-md-2" />
</div>
</div>
</div>
)
}
}
export default connect(
state => ({
message: state.message,
user: state.user,
}),
dispatch => ({
onAddSport: e => {
const data = { label: e.target.form.label.value }
dispatch(addData('sports', data))
},
})
)(AdminSportsAdd)

View File

@ -0,0 +1,155 @@
import React from 'react'
import { Helmet } from 'react-helmet'
import { connect } from 'react-redux'
import { deleteData, updateData } from '../../../actions/index'
import { history } from '../../../index'
class AdminDetail extends React.Component {
constructor(props, context) {
super(props, context)
this.state = {
isInEdition: false,
}
}
render() {
const {
message,
onDataUpdate,
onDataDelete,
results,
target,
} = this.props
const { isInEdition } = this.state
const title = target.charAt(0).toUpperCase() + target.slice(1)
return (
<div>
<Helmet>
<title>FitTrackee - Admin</title>
</Helmet>
<h1 className="page-title">
Administration - {title}
</h1>
{message ? (
<code>{message}</code>
) : (
results.length === 1 && (
<div className="container">
<div className="row">
<div className="col-md-2" />
<div className="col-md-8 card">
<div className="card-body">
<form onSubmit={event =>
event.preventDefault()}
>
{ Object.keys(results[0])
.filter(key => key.charAt(0) !== '_')
.map(key => (
<div className="form-group" key={key}>
<label>
{key}:
{key === 'img' ? (
<img
src={results[0][key]
? results[0][key]
: '/img/photo.png'}
alt="property"
/>
) : (
<input
className="form-control input-lg"
name={key}
readOnly={key === 'id' || !isInEdition}
defaultValue={results[0][key]}
/>
)}
</label>
</div>
))
}
{isInEdition ? (
<div>
<input
type="submit"
className="btn btn-primary btn-lg btn-block"
onClick={event => {
onDataUpdate(event, target)
this.setState({ isInEdition: false })
}
}
value="Submit"
/>
<input
type="submit"
className="btn btn-secondary btn-lg btn-block"
onClick={event => {
event.target.form.reset()
this.setState({ isInEdition: false })
}}
value="Cancel"
/>
</div>
) : (
<div>
<input
type="submit"
className="btn btn-primary btn-lg btn-block"
onClick={() => this.setState({ isInEdition: true })}
value="Edit"
/>
<input
type="submit"
className="btn btn-danger btn-lg btn-block"
disabled={!results[0]._can_be_deleted}
onClick={event => onDataDelete(event, target)}
title={results[0]._can_be_deleted
? ''
: 'Can\'t be deleted, associated data exist'}
value="Delete"
/>
<input
type="submit"
className="btn btn-secondary btn-lg btn-block"
onClick={() => history.push(`/admin/${target}`)}
value="Back to the list"
/>
</div>
)}
</form>
</div>
</div>
<div className="col-md-2" />
</div>
</div>
)
)}
</div>
)
}
}
export default connect(
state => ({
message: state.message,
}),
dispatch => ({
onDataDelete: (e, target) => {
const id = e.target.form.id.value
dispatch(deleteData(target, id))
},
onDataUpdate: (e, target) => {
const data = [].slice
.call(e.target.form.elements)
.reduce(function(map, obj) {
if (obj.name) {
map[obj.name] = obj.value
}
return map
}, {})
dispatch(updateData(target, data))
},
})
)(AdminDetail)

View File

@ -0,0 +1,97 @@
import React from 'react'
import { Helmet } from 'react-helmet'
import { Link } from 'react-router-dom'
import { history } from '../../../index'
export default function AdminPage(props) {
const { data, target } = props
const { error } = data
const results = data.data
const tbKeys = []
if (results.length > 0) {
Object.keys(results[0])
.filter(key => key.charAt(0) !== '_')
.map(key => tbKeys.push(key))
}
const title = target.charAt(0).toUpperCase() + target.slice(1)
return (
<div>
<Helmet>
<title>FitTrackee - Admin</title>
</Helmet>
<h1 className="page-title">
Administration - {title}
</h1>
{error ? (
<code>{error}</code>
) : (
<div className="container">
<div className="row">
<div className="col-md-2" />
<div className="col-md-8 card">
<div className="card-body">
<table className="table">
<thead>
<tr>
{tbKeys.map(
tbKey => <th key={tbKey} scope="col">{tbKey}</th>
)}
</tr>
</thead>
<tbody>
{ results.map((result, idx) => (
// eslint-disable-next-line react/no-array-index-key
<tr key={idx}>
{ Object.keys(result)
.filter(key => key.charAt(0) !== '_')
.map(key => {
if (key === 'id') {
return (
<th key={key} scope="row">
<Link to={`/admin/${target}/${result[key]}`}>
{result[key]}
</Link>
</th>
)
} else if (key === 'img') {
return (<td key={key}>
<img
className="admin-img"
src={result[key]
? result[key]
: '/img/photo.png'}
alt="logo"
/>
</td>)
}
return <td key={key}>{result[key]}</td>
})
}
</tr>
))}
</tbody>
</table>
<input
type="submit"
className="btn btn-primary btn-lg btn-block"
onClick={() => history.push(`/admin/${target}/add`)}
value="Add a new item"
/>
<input
type="submit"
className="btn btn-secondary btn-lg btn-block"
onClick={() => history.push('/admin/')}
value="Back"
/>
</div>
</div>
<div className="col-md-2" />
</div>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,48 @@
import React from 'react'
import { Helmet } from 'react-helmet'
import { connect } from 'react-redux'
import { Redirect, Route, Switch } from 'react-router-dom'
import AdminMenu from './Sports/AdminMenu'
import AdminSport from './Sports/AdminSport'
import AdminSports from './Sports/AdminSports'
import AdminSportsAdd from './Sports/AdminSportsAdd'
import AccessDenied from './../Others/AccessDenied'
import NotFound from './../Others/NotFound'
import { isLoggedIn } from '../../utils'
function Admin (props) {
const { user } = props
return (
<div>
<Helmet>
<title>FitTrackee - Admin</title>
</Helmet>
{isLoggedIn() ? (
user.isAdmin ? (
<Switch>
<Route exact path="/admin" component={AdminMenu} />
<Route exact path="/admin/sports" component={AdminSports} />
<Route
exact path="/admin/sports/add"
component={AdminSportsAdd}
/>
<Route
exact path="/admin/sports/:sportId"
component={AdminSport}
/>
<Route component={NotFound} />
</Switch>
) : (
<AccessDenied />
)
) : (<Redirect to="/login" />)}
</div>
)
}
export default connect(
state => ({
user: state.user,
})
)(Admin)

View File

@ -0,0 +1,341 @@
.App {
background-color: #eaeaea;
min-height: 100vh;
text-align: center;
}
.App-logo {
animation: App-logo-spin infinite 20s linear;
height: 80px;
}
.App-header {
background-color: #222;
height: 150px;
padding: 20px;
color: white;
}
.App-title {
font-size: 1.5em;
}
.App-intro {
font-size: large;
}
.App-nav-profile-img {
max-width: 35px;
max-height: 35px;
border-radius: 50%;
}
.App-profile-img-small {
max-width: 150px;
max-height: 150px;
border-radius: 50%;
}
@keyframes App-logo-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
a {
color: #40578a;
}
.card {
text-align: left;
}
label {
width: 100%;
}
input, textarea {
width: 100%;
}
.page-title {
font-size: 2em;
margin: 1em;
text-align: center;
}
.activity-card {
margin-bottom: 15px;
}
.activity-date {
font-size: 0.75em;
}
.activity-page {
margin-top: 20px;
}
.activity-sport {
margin-right: 1px;
max-width: 20px;
max-height: 20px;
}
.add-activity {
margin-top: 50px;
}
.admin-img {
max-width: 35px;
max-height: 35px;
}
.col-activity-logo{
padding-right: 0;
}
.fa-trophy {
color: goldenrod;
}
.fa-color {
color: #405976;
}
.leaflet-container {
height: 400px;
}
.radioLabel {
text-align: center;
}
.chart {
font-size: 0.9em;
}
.chart-info {
font-size: 0.8em;
font-style: italic;
}
.chart-month {
font-size: 0.8em;
}
.chart-radio {
display: flex;
font-size: 0.9em;
}
.chart-radio label {
display: flex;
}
.chart-radio input {
margin-right: 10px;
}
.chart-title {
font-size: 1.1em;
margin-bottom: 10px;
}
.custom-modal {
background-color: #fff;
border-radius: 5px;
max-width: 500px;
margin: 20% auto;
z-index: 1250;
}
.custom-modal-backdrop {
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
background-color: rgba(0,0,0,0.3);
padding: 50px;
z-index: 1240;
}
.custom-fa {
margin-right: 5px;
}
.dashboard {
margin-top: 30px;
}
.sport-img {
max-width: 35px;
max-height: 35px;
}
.huge {
font-size: 25px;
}
.inactive-link {
color: lightgrey;
}
.img-disabled {
opacity: .4;
}
.record-logo {
margin-right: 5px;
max-width: 25px;
max-height: 25px;
}
.record-table table, .record-table th, .record-table td{
font-size: 0.9em;
padding: 0.1em;
}
.sport-img-medium {
max-width: 45px;
max-height: 45px;
}
.unlink {
color: black;
}
.loader {
animation: spin 2s linear infinite;
border: 16px solid #f3f3f3;
border-top: 16px solid #3498db;
border-radius: 50%;
height: 120px;
margin-left: 41%;
width: 120px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* calendar */
:root {
--main-color: #1a8fff;
--text-color: #777;
--text-color-light: #ccc;
--border-color: #eee;
--bg-color: #f9f9f9;
--neutral-color: #fff;
}
.calendar .col-start {
justify-content: flex-start;
text-align: left;
}
.calendar .col-center {
justify-content: center;
text-align: center;
}
.calendar .col-end {
justify-content: flex-end;
text-align: right;
}
.calendar {
display: block;
position: relative;
width: 100%;
background: var(--neutral-color);
border: 1px solid var(--border-color);
}
.calendar .header {
text-transform: uppercase;
font-weight: 700;
/*font-size: 115%;*/
padding: 0.5em 0;
border-bottom: 1px solid var(--border-color);
}
.calendar .header .icon {
cursor: pointer;
transition: .15s ease-out;
}
.calendar .header .icon:hover {
transform: scale(1.75);
transition: .25s ease-out;
color: var(--main-color);
}
.calendar .header .icon:first-of-type {
margin-left: 1em;
}
.calendar .header .icon:last-of-type {
margin-right: 1em;
}
.calendar .days {
text-transform: uppercase;
font-weight: 400;
color: var(--text-color-light);
font-size: 70%;
padding: .75em 0;
border-bottom: 1px solid var(--border-color);
}
.calendar .body .cell {
position: relative;
height: 3em;
border-right: 1px solid var(--border-color);
overflow: hidden;
cursor: pointer;
background: var(--neutral-color);
}
.calendar .body .cell:hover {
background: var(--bg-color);
}
.calendar .body .selected {
border-left: 10px solid transparent;
border-image: linear-gradient(45deg, #1a8fff 0%,#53cbf1 40%);
}
.calendar .body .row {
border-bottom: 1px solid var(--border-color);
margin: 0;
}
.calendar .body .row:last-child {
border-bottom: none;
}
.calendar .body .cell:last-child {
border-right: none;
}
.calendar .body .cell .number {
position: absolute;
font-size: 82.5%;
line-height: 1;
top: .75em;
right: .75em;
font-weight: 700;
}
.calendar .body .disabled {
color: var(--text-color-light);
pointer-events: none;
}
.calendar .body .col {
flex-grow: 0;
flex-basis: calc(100%/7);
width: calc(100%/7);
}

View File

@ -0,0 +1,94 @@
import React from 'react'
import { Redirect, Route, Switch } from 'react-router-dom'
import './App.css'
import Admin from './Admin'
import Activity from './Activity/index'
import Dashboard from './Dashboard'
import Logout from './User/Logout'
import NavBar from './NavBar'
import NotFound from './Others/NotFound'
import Profile from './User/Profile'
import ProfileEdit from './User/ProfileEdit'
import UserForm from './User/UserForm'
import { isLoggedIn } from '../utils'
export default class App extends React.Component {
constructor(props) {
super(props)
this.props = props
}
render() {
return (
<div className="App">
<NavBar />
<Switch>
<Route
exact path="/"
render={() => (
isLoggedIn() ? (
<Dashboard />
) : (
<Redirect to="/login" />
)
)}
/>
<Route
exact path="/register"
render={() => (
isLoggedIn() ? (
<Redirect to="/" />
) : (
<UserForm
formType={'Register'}
/>
)
)}
/>
<Route
exact path="/login"
render={() => (
isLoggedIn() ? (
<Redirect to="/" />
) : (
<UserForm
formType={'Login'}
/>
)
)}
/>
<Route exact path="/logout" component={Logout} />
<Route
exact path="/profile/edit"
render={() => (
isLoggedIn() ? (
<ProfileEdit />
) : (
<UserForm
formType={'Login'}
/>
)
)}
/>
<Route
exact path="/profile"
render={() => (
isLoggedIn() ? (
<Profile />
) : (
<UserForm
formType={'Login'}
/>
)
)}
/>
<Route path="/activities" component={Activity} />
<Route path="/admin" component={Admin} />
<Route component={NotFound} />
</Switch>
</div>
)
}
}

View File

@ -0,0 +1,9 @@
import React from 'react'
import ReactDOM from 'react-dom'
import App from './App'
it('renders without crashing', () => {
const div = document.createElement('div')
ReactDOM.render(<App />, div)
})

View File

@ -0,0 +1,48 @@
import { format } from 'date-fns'
import React from 'react'
import { Link } from 'react-router-dom'
import { apiUrl } from '../../utils'
export default function ActivityCard (props) {
const { activity, sports } = props
return (
<div className="card activity-card text-center">
<div className="card-header">
<Link to={`/activities/${activity.id}`}>
{sports.filter(sport => sport.id === activity.sport_id)
.map(sport => sport.label)} -{' '}
{format(activity.activity_date, 'DD/MM/YYYY HH:mm')}
</Link>
</div>
<div className="card-body">
<div className="row">
{activity.map && (
<div className="col">
<img
alt="Map"
src={`${apiUrl}activities/map/${activity.map}` +
`?${Date.now()}`}
className="img-fluid"
/>
</div>
)}
<div className="col">
<p>
<i className="fa fa-clock-o" aria-hidden="true" />{' '}
Duration: {activity.duration}
{activity.map ? (
<span><br /><br /></span>
) : (
' - '
)}
<i className="fa fa-road" aria-hidden="true" />{' '}
Distance: {activity.distance} km
</p>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,69 @@
import React from 'react'
import { Link } from 'react-router-dom'
import { formatRecord } from '../../utils'
export default function RecordsCard (props) {
const { records, sports } = props
const recordsBySport = records.reduce((sportList, record) => {
const sport = sports.find(s => s.id === record.sport_id)
if (sportList[sport.label] === void 0) {
sportList[sport.label] = {
img: sport.img,
records: [],
}
}
sportList[sport.label].records.push(formatRecord(record))
return sportList
}, {})
return (
<div className="card activity-card">
<div className="card-header">
Personal records
</div>
<div className="card-body">
{Object.keys(recordsBySport).length === 0
? 'No records'
: (Object.keys(recordsBySport).map(sportLabel => (
<table
className="table table-borderless record-table"
key={sportLabel}
>
<thead>
<tr>
<th colSpan="3">
<img
alt={`${sportLabel} logo`}
className="record-logo"
src={recordsBySport[sportLabel].img}
/>
{sportLabel}
</th>
</tr>
</thead>
<tbody>
{recordsBySport[sportLabel].records.map(rec => (
<tr key={rec.id}>
<td>
{rec.record_type}
</td>
<td>
{rec.value}
</td>
<td>
<Link to={`/activities/${rec.activity_id}`}>
{rec.activity_date}
</Link>
</td>
</tr>
))}
</tbody>
</table>))
)
}
</div>
</div>
)
}

View File

@ -0,0 +1,138 @@
import { endOfMonth, format, startOfMonth } from 'date-fns'
import React from 'react'
import { connect } from 'react-redux'
import {
Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis
} from 'recharts'
import { getStats } from '../../actions/stats'
import { activityColors, formatStats } from '../../utils'
class Statistics extends React.Component {
constructor(props, context) {
super(props, context)
const date = new Date()
this.state = {
start: startOfMonth(date),
end: endOfMonth(date),
displayedData: 'distance'
}
}
componentDidMount() {
this.props.loadMonthActivities(
this.props.user.id,
this.state.start,
this.state.end,
)
}
handleRadioChange (changeEvent) {
this.setState({
displayedData: changeEvent.target.name
})
}
render() {
const { sports, statistics } = this.props
const { displayedData, end, start } = this.state
const stats = formatStats(statistics, sports, start, end)
return (
<div className="card activity-card">
<div className="card-header">
This month
</div>
<div className="card-body">
{Object.keys(statistics).length === 0 ? (
'No workouts'
) : (
<div className="chart-month">
<div className="row chart-radio">
<label className="radioLabel col">
<input
type="radio"
name="distance"
checked={displayedData === 'distance'}
onChange={e => this.handleRadioChange(e)}
/>
distance
</label>
<label className="radioLabel col">
<input
type="radio"
name="duration"
checked={displayedData === 'duration'}
onChange={e => this.handleRadioChange(e)}
/>
duration
</label>
<label className="radioLabel col">
<input
type="radio"
name="activities"
checked={displayedData === 'activities'}
onChange={e => this.handleRadioChange(e)}
/>
activities
</label>
</div>
<ResponsiveContainer height={300}>
<BarChart
data={stats[displayedData]}
margin={{ top: 15, bottom: 15 }}
>
<XAxis
dataKey="date"
interval={0} // to force to display all ticks
/>
<YAxis
tickFormatter={value => displayedData === 'distance'
? `${value} km`
: displayedData === 'duration'
? format(new Date(value * 1000), 'HH:mm')
: value
}
/>
<Tooltip />
{sports.map((s, i) => (
<Bar
key={s.id}
dataKey={s.label}
formatter={value => displayedData === 'duration'
? format(new Date(value * 1000), 'HH:mm')
: value
}
stackId="a"
fill={activityColors[i]}
unit={displayedData === 'distance' ? ' km' : ''}
/>
))}
</BarChart>
</ResponsiveContainer>
</div>
)}
</div>
</div>
)
}
}
export default connect(
state => ({
sports: state.sports.data,
statistics: state.statistics.data,
user: state.user,
}),
dispatch => ({
loadMonthActivities: (userId, start, end) => {
const dateFormat = 'YYYY-MM-DD'
const params = {
start: format(start, dateFormat),
end: format(end, dateFormat),
time: 'week'
}
dispatch(getStats(userId, 'by_time', params))
},
})
)(Statistics)

View File

@ -0,0 +1,64 @@
import React from 'react'
export default function UserStatistics (props) {
const { user } = props
return (
<div className="row">
<div className="col">
<div className="card activity-card">
<div className="card-body row">
<div className="col-3">
<i className="fa fa-calendar fa-3x fa-color" />
</div>
<div className="col-9 text-right">
<div className="huge">{user.nbActivities}</div>
<div>{`workout${user.nbActivities === 1 ? '' : 's'}`}</div>
</div>
</div>
</div>
</div>
<div className="col">
<div className="card activity-card">
<div className="card-body row">
<div className="col-3">
<i className="fa fa-road fa-3x fa-color" />
</div>
<div className="col-9 text-right">
<div className="huge">
{Math.round(user.totalDistance * 100) / 100}
</div>
<div>km</div>
</div>
</div>
</div>
</div>
<div className="col">
<div className="card activity-card">
<div className="card-body row">
<div className="col-3">
<i className="fa fa-clock-o fa-3x fa-color" />
</div>
<div className="col-9 text-right">
<div className="huge">{user.totalDuration}</div>
<div>total duration</div>
</div>
</div>
</div>
</div>
<div className="col">
<div className="card activity-card">
<div className="card-body row">
<div className="col-3">
<i className="fa fa-tags fa-3x fa-color" />
</div>
<div className="col-9 text-right">
<div className="huge">{user.nbSports}</div>
<div>{`sport${user.nbSports === 1 ? '' : 's'}`}</div>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,107 @@
import React from 'react'
import { Helmet } from 'react-helmet'
import { connect } from 'react-redux'
import { Link } from 'react-router-dom'
import ActivityCard from './ActivityCard'
import Calendar from './../Others/Calendar'
import Records from './Records'
import Statistics from './Statistics'
import UserStatistics from './UserStatistics'
import { getData } from '../../actions'
import { getMoreActivities } from '../../actions/activities'
class DashBoard extends React.Component {
constructor(props, context) {
super(props, context)
this.state = {
page: 1,
}
}
componentDidMount() {
this.props.loadActivities()
}
render() {
const {
activities, loadMoreActivities, message, records, sports, user
} = this.props
const paginationEnd = activities.length > 0
? activities[activities.length - 1].previous_activity === null
: true
const { page } = this.state
return (
<div>
<Helmet>
<title>FitTrackee - Dashboard</title>
</Helmet>
{message ? (
<code>{message}</code>
) : (
(activities && sports.length > 0) && (
<div className="container dashboard">
<UserStatistics user={user} />
<div className="row">
<div className="col-md-4">
<Statistics />
<Records records={records} sports={sports} />
</div>
<div className="col-md-8">
<Calendar />
{activities.length > 0 ? (
activities.map(activity => (
<ActivityCard
activity={activity}
key={activity.id}
sports={sports}
/>)
)) : (
<div className="card text-center">
<div className="card-body">
No workouts. {' '}
<Link to={{ pathname: '/activities/add' }}>
Upload one !
</Link>
</div>
</div>
)}
{!paginationEnd &&
<input
type="submit"
className="btn btn-default btn-md btn-block"
value="Load more activities"
onClick={() => {
loadMoreActivities(page + 1)
this.setState({ page: page + 1 })
}}
/>
}
</div>
</div>
</div>
)
)}
</div>
)
}
}
export default connect(
state => ({
activities: state.activities.data,
message: state.message,
records: state.records.data,
sports: state.sports.data,
user: state.user,
}),
dispatch => ({
loadActivities: () => {
dispatch(getData('activities', { page: 1 }))
dispatch(getData('records'))
},
loadMoreActivities: page => {
dispatch(getMoreActivities(page))
},
})
)(DashBoard)

View File

@ -0,0 +1,131 @@
import React from 'react'
import { connect } from 'react-redux'
import { Link } from 'react-router-dom'
import { apiUrl } from '../../utils'
function NavBar(props) {
return (
<header>
<nav className="navbar navbar-expand-lg navbar-light bg-light">
<div className="container">
<span className="navbar-brand">FitTrackee</span>
<button
className="navbar-toggler"
type="button"
data-toggle="collapse"
data-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent"
aria-expanded="false"
aria-label="Toggle navigation"
>
<span className="navbar-toggler-icon" />
</button>
<div className="collapse navbar-collapse" id="navbarSupportedContent">
<ul className="navbar-nav mr-auto">
<li className="nav-item">
<Link
className="nav-link"
to={{
pathname: '/',
}}
>
Dashboard
</Link>
</li>
{props.user.isAuthenticated && (
<li className="nav-item">
<Link
className="nav-link"
to={{
pathname: '/activities/add',
}}
>
Add workout
</Link>
</li>
)}
{props.user.isAdmin && (
<li className="nav-item">
<Link
className="nav-link"
to={{
pathname: '/admin',
}}
>
Admin
</Link>
</li>
)}
</ul>
<ul className="navbar-nav flex-row ml-md-auto d-none d-md-flex">
{!props.user.isAuthenticated && (
<li className="nav-item">
<Link
className="nav-link"
to={{
pathname: '/register',
}}
>
Register
</Link>
</li>
)}
{!props.user.isAuthenticated && (
<li className="nav-item">
<Link
className="nav-link"
to={{
pathname: '/login',
}}
>
Login
</Link>
</li>
)}
{props.user.picture === true && (
<img
alt="Avatar"
src={`${apiUrl}users/${props.user.id}/picture` +
`?${Date.now()}`}
className="img-fluid App-nav-profile-img"
/>
)}
{props.user.isAuthenticated && (
<li className="nav-item">
<Link
className="nav-link"
to={{
pathname: '/profile',
}}
>
{props.user.username}
</Link>
</li>
)}
{props.user.isAuthenticated && (
<li className="nav-item">
<Link
className="nav-link"
to={{
pathname: '/logout',
}}
>
Logout
</Link>
</li>
)}
</ul>
</div>
</div>
</nav>
</header>
)
}
export default connect(
state => ({
user: state.user,
})
)(NavBar)

View File

@ -0,0 +1,16 @@
import React from 'react'
import { Helmet } from 'react-helmet'
export default function AccessDenied () {
return (
<div>
<Helmet>
<title>FitTrackee - Access denied</title>
</Helmet>
<h1 className="page-title">Access denied</h1>
<p className="App-center">
{'You don\'t have permissions to access this page.'}
</p>
</div>
)
}

View File

@ -0,0 +1,180 @@
// eslint-disable-next-line max-len
// source: https://blog.flowandform.agency/create-a-custom-calendar-in-react-3df1bfd0b728
import dateFns from 'date-fns'
import React from 'react'
import { connect } from 'react-redux'
import { Link } from 'react-router-dom'
import { getMonthActivities } from '../../actions/activities'
const getStartAndEndMonth = date => {
const monthStart = dateFns.startOfMonth(date)
const monthEnd = dateFns.endOfMonth(date)
return {
start: dateFns.startOfWeek(monthStart),
end: dateFns.endOfWeek(monthEnd),
}
}
class Calendar extends React.Component {
constructor(props, context) {
super(props, context)
const calendarDate = new Date()
this.state = {
currentMonth: calendarDate,
startDate: getStartAndEndMonth(calendarDate).start,
endDate: getStartAndEndMonth(calendarDate).end,
}
}
componentDidMount() {
this.props.loadMonthActivities(this.state.startDate, this.state.endDate)
}
renderHeader() {
const dateFormat = 'MMM YYYY'
return (
<div className="header row flex-middle">
<div className="col col-start" onClick={() => this.handlePrevMonth()}>
<i
className="fa fa-chevron-left"
aria-hidden="true"
/>
</div>
<div className="col col-center">
<span>
{dateFns.format(this.state.currentMonth, dateFormat)}
</span>
</div>
<div className="col col-end" onClick={() => this.handleNextMonth()}>
<i
className="fa fa-chevron-right"
aria-hidden="true"
/>
</div>
</div>
)
}
renderDays() {
const dateFormat = 'ddd'
const days = []
const { startDate } = this.state
for (let i = 0; i < 7; i++) {
days.push(
<div className="col col-center" key={i}>
{dateFns.format(dateFns.addDays(startDate, i), dateFormat)}
</div>
)
}
return <div className="days row">{days}</div>
}
filterActivities(day) {
const { activities } = this.props
if (activities) {
return activities
.filter(act => dateFns.isSameDay(act.activity_date, day))
}
return []
}
renderCells() {
const { currentMonth, startDate, endDate } = this.state
const { sports } = this.props
const dateFormat = 'D'
const rows = []
let days = []
let day = startDate
let formattedDate = ''
while (day <= endDate) {
for (let i = 0; i < 7; i++) {
formattedDate = dateFns.format(day, dateFormat)
const dayActivities = this.filterActivities(day)
const isDisabled = dateFns.isSameMonth(day, currentMonth)
? ''
: 'disabled'
days.push(
<div
className={`col cell img-${isDisabled}`}
key={day}
>
<span className="number">{formattedDate}</span>
{dayActivities.map(act => (
<Link key={act.id} to={`/activities/${act.id}`}>
<img
className={`activity-sport ${isDisabled}`}
src={sports
.filter(s => s.id === act.sport_id)
.map(s => s.img)}
alt="activity sport logo"
/>
</Link>
))}
</div>
)
day = dateFns.addDays(day, 1)
}
rows.push(
<div className="row" key={day}>
{days}
</div>
)
days = []
}
return <div className="body">{rows}</div>
}
updateStateDate (calendarDate) {
const { start, end } = getStartAndEndMonth(calendarDate)
this.setState({
currentMonth: calendarDate,
startDate: start,
endDate: end,
})
this.props.loadMonthActivities(start, end)
}
handleNextMonth () {
const calendarDate = dateFns.addMonths(this.state.currentMonth, 1)
this.updateStateDate(calendarDate)
}
handlePrevMonth () {
const calendarDate = dateFns.subMonths(this.state.currentMonth, 1)
this.updateStateDate(calendarDate)
}
render() {
return (
<div className="card activity-card">
<div className="calendar">
{this.renderHeader()}
{this.renderDays()}
{this.renderCells()}
</div>
</div>
)
}
}
export default connect(
state => ({
activities: state.calendarActivities.data,
sports: state.sports.data,
}),
dispatch => ({
loadMonthActivities: (start, end) => {
const dateFormat = 'YYYY-MM-DD'
dispatch(getMonthActivities(
dateFns.format(start, dateFormat),
dateFns.format(end, dateFormat),
))
},
})
)(Calendar)

View File

@ -0,0 +1,42 @@
import React from 'react'
export default function CustomModal(props) {
return (
<div className="custom-modal-backdrop">
<div className="custom-modal">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">{props.title}</h5>
<button
type="button"
className="close"
aria-label="Close"
onClick={() => props.close}
>
<span aria-hidden="true">&times;</span>
</button>
</div>
<div className="modal-body">
<p>{props.text}</p>
</div>
<div className="modal-footer">
<button
type="button"
className="btn btn-primary"
onClick={() => props.confirm()}
>
Yes
</button>
<button
type="button"
className="btn btn-secondary"
onClick={() => props.close()}
>
No
</button>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,13 @@
import React from 'react'
import { Helmet } from 'react-helmet'
export default function NotFound () {
return (
<div>
<Helmet>
<title>fittrackee - 404</title>
</Helmet>
<h1 className="page-title">Page not found</h1>
</div>
)
}

View File

@ -0,0 +1,11 @@
import React from 'react'
import { Provider } from 'react-redux'
import { ConnectedRouter } from 'react-router-redux'
export default function Root({ store, history, children }) {
return (
<Provider store={store}>
<ConnectedRouter history={history}>{children}</ConnectedRouter>
</Provider>
)
}

View File

@ -0,0 +1,79 @@
import React from 'react'
import { Helmet } from 'react-helmet'
export default function Form (props) {
return (
<div>
<Helmet>
<title>FitTrackee - {props.formType}</title>
</Helmet>
<h1 className="page-title">{props.formType}</h1>
<div className="container">
<div className="row">
<div className="col-md-3" />
<div className="col-md-6">
<hr /><br />
<form onSubmit={event =>
props.handleUserFormSubmit(event, props.formType)}
>
{props.formType === 'Register' &&
<div className="form-group">
<input
className="form-control input-lg"
name="username"
placeholder="Enter a username"
required
type="text"
value={props.userForm.username}
onChange={props.onHandleFormChange}
/>
</div>
}
<div className="form-group">
<input
className="form-control input-lg"
name="email"
placeholder="Enter an email address"
required
type="email"
value={props.userForm.email}
onChange={props.onHandleFormChange}
/>
</div>
<div className="form-group">
<input
className="form-control input-lg"
name="password"
placeholder="Enter a password"
required
type="password"
value={props.userForm.password}
onChange={props.onHandleFormChange}
/>
</div>
{props.formType === 'Register' &&
<div className="form-group">
<input
className="form-control input-lg"
name="passwordConf"
placeholder="Enter the password confirmation"
required
type="password"
value={props.userForm.passwordConf}
onChange={props.onHandleFormChange}
/>
</div>
}
<input
type="submit"
className="btn btn-primary btn-lg btn-block"
value="Submit"
/>
</form>
</div>
<div className="col-md-3" />
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,31 @@
import React from 'react'
import { connect } from 'react-redux'
import { Link } from 'react-router-dom'
import { logout } from '../../actions/user'
class Logout extends React.Component {
componentDidMount() {
this.props.UserLogout()
}
render() {
return (
<div>
<p className="App-center">
You are now logged out.
Click <Link to="/login">here</Link> to log back in.</p>
</div>
)
}
}
export default connect(
state => ({
user: state.user,
}),
dispatch => ({
UserLogout: () => {
dispatch(logout())
}
})
)(Logout)

View File

@ -0,0 +1,102 @@
import { format } from 'date-fns'
import React from 'react'
import { Helmet } from 'react-helmet'
import { connect } from 'react-redux'
import { Link } from 'react-router-dom'
import { deletePicture, uploadPicture } from '../../actions/user'
import { apiUrl } from '../../utils'
function Profile ({ message, onDeletePicture, onUploadPicture, user }) {
return (
<div>
<Helmet>
<title>FitTrackee - {user.username} - Profile</title>
</Helmet>
{ message !== '' && (
<code>{message}</code>
)}
<div className="container">
<h1 className="page-title">Profile</h1>
<div className="row">
<div className="col-md-12">
<div className="card">
<div className="card-header userName">
{user.username} {' '}
<Link
to={{
pathname: '/profile/edit',
}}
>
<i className="fa fa-pencil-square-o" aria-hidden="true" />
</Link>
</div>
<div className="card-body">
<div className="row">
<div className="col-md-8">
<p>Email: {user.email}</p>
<p>Registration Date: {
format(new Date(user.createdAt), 'DD/MM/YYYY HH:mm')
}</p>
<p>First Name: {user.firstName}</p>
<p>Last Name: {user.lastName}</p>
<p>Birth Date: {user.birthDate}</p>
<p>Location: {user.location}</p>
<p>Bio: {user.bio}</p>
</div>
<div className="col-md-4">
{ user.picture === true && (
<div>
<img
alt="Profile"
src={`${apiUrl}users/${user.id}/picture` +
`?${Date.now()}`}
className="img-fluid App-profile-img-small"
/>
<br />
<button
type="submit"
onClick={() => onDeletePicture()}
>
Delete picture
</button>
<br /><br />
</div>
)}
<form
encType="multipart/form-data"
onSubmit={event => onUploadPicture(event)}
>
<input
type="file"
name="picture"
accept=".png,.jpg,.gif"
/>
<br />
<button type="submit">Send</button>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
export default connect(
state => ({
message: state.message,
user: state.user,
}),
dispatch => ({
onDeletePicture: () => {
dispatch(deletePicture())
},
onUploadPicture: event => {
dispatch(uploadPicture(event))
},
})
)(Profile)

View File

@ -0,0 +1,197 @@
import React from 'react'
import { Helmet } from 'react-helmet'
import { connect } from 'react-redux'
import {
initProfileForm,
updateProfileFormData,
handleProfileFormSubmit
} from '../../actions/user'
import { history } from '../../index'
class ProfileEdit extends React.Component {
componentDidMount() {
this.props.initForm(this.props.user)
}
render () {
const { formProfile,
onHandleFormChange,
onHandleProfileFormSubmit,
message,
user
} = this.props
return (
<div>
<Helmet>
<title>FitTrackee - {user.username} - Edit Profile</title>
</Helmet>
{ message !== '' && (
<code>{message}</code>
)}
<div className="container">
<h1 className="page-title">Profile Edition</h1>
<div className="row">
<div className="col-md-2" />
<div className="col-md-8">
<div className="card">
<div className="card-header">
{user.username}
</div>
<div className="card-body">
<div className="row">
<div className="col-md-12">
<form onSubmit={event =>
onHandleProfileFormSubmit(event)}
>
<div className="form-group">
<label>Email:
<input
name="email"
className="form-control input-lg"
type="text"
value={user.email}
readOnly
/>
</label>
</div>
<div className="form-group">
<label>
Registration Date:
<input
name="createdAt"
className="form-control input-lg"
type="text"
value={user.createdAt}
readOnly
/>
</label>
</div>
<div className="form-group">
<label>
Password:
<input
name="password"
className="form-control input-lg"
type="password"
onChange={onHandleFormChange}
/>
</label>
</div>
<div className="form-group">
<label>
Password Confirmation:
<input
name="passwordConf"
className="form-control input-lg"
type="password"
onChange={onHandleFormChange}
/>
</label>
</div>
<hr />
<div className="form-group">
<label>
First Name:
<input
name="firstName"
className="form-control input-lg"
type="text"
value={formProfile.firstName}
onChange={onHandleFormChange}
/>
</label>
</div>
<div className="form-group">
<label>
Last Name:
<input
name="lastName"
className="form-control input-lg"
type="text"
value={formProfile.lastName}
onChange={onHandleFormChange}
/>
</label>
</div>
<div className="form-group">
<label>
Birth Date
<input
name="birthDate"
className="form-control input-lg"
type="date"
value={formProfile.birthDate}
onChange={onHandleFormChange}
/>
</label>
</div>
<div className="form-group">
<label>
Location:
<input
name="location"
className="form-control input-lg"
type="text"
value={formProfile.location}
onChange={onHandleFormChange}
/>
</label>
</div>
<div className="form-group">
<label>
Bio:
<textarea
name="bio"
className="form-control input-lg"
maxLength="200"
type="text"
value={formProfile.bio}
onChange={onHandleFormChange}
/>
</label>
</div>
<input
type="submit"
className="btn btn-primary btn-lg btn-block"
value="Submit"
/>
<input
type="submit"
className="btn btn-secondary btn-lg btn-block"
onClick={() => history.push('/profile')}
value="Cancel"
/>
</form>
</div>
</div>
</div>
</div>
</div>
<div className="col-md-2" />
</div>
</div>
</div>
)
}
}
export default connect(
state => ({
formProfile: state.formProfile.formProfile,
message: state.message,
user: state.user,
}),
dispatch => ({
initForm: () => {
dispatch(initProfileForm())
},
onHandleFormChange: event => {
dispatch(updateProfileFormData(event.target.name, event.target.value))
},
onHandleProfileFormSubmit: event => {
dispatch(handleProfileFormSubmit(event))
},
})
)(ProfileEdit)

View File

@ -0,0 +1,84 @@
import React from 'react'
import { connect } from 'react-redux'
import { Redirect } from 'react-router-dom'
import Form from './Form'
import {
emptyForm,
handleFormChange,
handleUserFormSubmit
} from '../../actions/user'
import { isLoggedIn } from '../../utils'
class UserForm extends React.Component {
componentDidUpdate(prevProps) {
if (
(prevProps.location.pathname !== this.props.location.pathname)
) {
this.props.onEmptyForm()
}
}
render() {
const {
formData,
formType,
message,
messages,
onHandleFormChange,
onHandleUserFormSubmit
} = this.props
return (
<div>
{isLoggedIn() ? (
<Redirect to="/" />
) : (
<div>
{message !== '' && (
<code>{message}</code>
)}
{messages.length > 0 && (
<code>
<ul>
{messages.map(msg => (
<li key={msg.id}>
{msg.value}
</li>
))}
</ul>
</code>
)}
<Form
formType={formType}
userForm={formData}
onHandleFormChange={event => onHandleFormChange(event)}
handleUserFormSubmit={event =>
onHandleUserFormSubmit(event, formType)
}
/>
</div>
)}
</div>
)
}
}
export default connect(
state => ({
formData: state.formData.formData,
location: state.router.location,
message: state.message,
messages: state.messages,
}),
dispatch => ({
onEmptyForm: () => {
dispatch(emptyForm())
},
onHandleFormChange: event => {
dispatch(handleFormChange(event.target.name, event.target.value))
},
onHandleUserFormSubmit: (event, formType) => {
dispatch(handleUserFormSubmit(event, formType))
},
})
)(UserForm)

View File

@ -0,0 +1,40 @@
import { apiUrl, createRequest } from '../utils'
export default class FitTrackeeApi {
static addActivity(formData) {
const params = {
url: `${apiUrl}activities`,
method: 'POST',
body: formData,
}
return createRequest(params)
}
static addActivityWithoutGpx(data) {
const params = {
url: `${apiUrl}activities/no_gpx`,
method: 'POST',
body: data,
type: 'application/json',
}
return createRequest(params)
}
static getActivityGpx(activityId) {
const params = {
url: `${apiUrl}activities/${activityId}/gpx`,
method: 'GET',
}
return createRequest(params)
}
static getActivityChartData(activityId) {
const params = {
url: `${apiUrl}activities/${activityId}/chart_data`,
method: 'GET',
}
return createRequest(params)
}
}

View File

@ -0,0 +1,59 @@
import { apiUrl, createRequest } from '../utils'
export default class FitTrackeeApi {
static getData(target,
data = {}) {
let url = `${apiUrl}${target}`
if (data.id) {
url = `${url}/${data.id}`
} else if (Object.keys(data).length > 0) {
url = `${url}?${
data.page ? `&page=${data.page}` : ''
}${
data.start ? `&from=${data.start}` : ''
}${
data.end ? `&to=${data.end}` : ''
}${
data.order ? `&order=${data.order}` : ''
}${
data.per_page ? `&per_page=${data.per_page}` : ''
}`
}
const params = {
url: url,
method: 'GET',
type: 'application/json',
}
return createRequest(params)
}
static addData(target, data) {
const params = {
url: `${apiUrl}${target}`,
method: 'POST',
body: data,
type: 'application/json',
}
return createRequest(params)
}
static updateData(target, data) {
const params = {
url: `${apiUrl}${target}/${data.id}`,
method: 'PATCH',
body: data,
type: 'application/json',
}
return createRequest(params)
}
static deleteData(target, id) {
const params = {
url: `${apiUrl}${target}/${id}`,
method: 'DELETE',
type: 'application/json',
}
return createRequest(params)
}
}

View File

@ -0,0 +1,25 @@
import { apiUrl, createRequest } from '../utils'
export default class FitTrackeeApi {
static getStats(userID, type, data = {}) {
let url = `${apiUrl}stats/${userID}/${type}`
if (Object.keys(data).length > 0) {
url = `${url}?${
data.start ? `&from=${data.start}` : ''
}${
data.end ? `&to=${data.end}` : ''
}${
data.time ? `&time=${data.time}` : ''
}${
data.sport_id ? `&sport_id=${data.sport_id}` : ''
}`
}
const params = {
url: url,
method: 'GET',
}
return createRequest(params)
}
}

View File

@ -0,0 +1,78 @@
import { apiUrl, createRequest } from '../utils'
export default class FitTrackeeApi {
static login(email, password) {
const params = {
url: `${apiUrl}auth/login`,
method: 'POST',
noAuthorization: true,
body: {
email: email,
password: password,
},
type: 'application/json',
}
return createRequest(params)
}
static register(username, email, password, passwordConf) {
const params = {
url: `${apiUrl}auth/register`,
method: 'POST',
noAuthorization: true,
body: {
username: username,
email: email,
password: password,
password_conf: passwordConf,
},
type: 'application/json',
}
return createRequest(params)
}
static getProfile() {
const params = {
url: `${apiUrl}auth/profile`,
method: 'GET',
type: 'application/json',
}
return createRequest(params)
}
static updateProfile(form) {
const params = {
url: `${apiUrl}auth/profile/edit`,
method: 'POST',
body: {
first_name: form.firstName,
last_name: form.lastName,
bio: form.bio,
location: form.location,
birth_date: form.birthDate,
password: form.password,
password_conf: form.passwordConf,
},
type: 'application/json',
}
return createRequest(params)
}
static updatePicture(form) {
const params = {
url: `${apiUrl}auth/picture`,
method: 'POST',
body: form,
}
return createRequest(params)
}
static deletePicture() {
const params = {
url: `${apiUrl}auth/picture`,
method: 'DELETE',
}
return createRequest(params)
}
}

View File

@ -0,0 +1,37 @@
/* eslint-disable react/jsx-filename-extension */
import { createBrowserHistory } from 'history'
import React from 'react'
import ReactDOM from 'react-dom'
import { routerMiddleware } from 'react-router-redux'
import { applyMiddleware, createStore, compose } from 'redux'
import thunk from 'redux-thunk'
import App from './components/App'
import Root from './components/Root'
import registerServiceWorker from './registerServiceWorker'
import reducers from './reducers'
import { loadProfile } from './actions/user'
export const history = createBrowserHistory()
export const rootNode = document.getElementById('root')
export const store = createStore(
reducers,
window.__STATE__, // Server state
(window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose)(
applyMiddleware(routerMiddleware(history), thunk)
)
)
if (window.localStorage.authToken !== null) {
store.dispatch(loadProfile())
}
ReactDOM.render(
<Root store={store} history={history}>
<App />
</Root>,
rootNode
)
registerServiceWorker()

View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3">
<g fill="#61DAFB">
<path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/>
<circle cx="420.9" cy="296.5" r="45.7"/>
<path d="M520.5 78.1z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -0,0 +1,218 @@
import { format } from 'date-fns'
import { routerReducer } from 'react-router-redux'
import { combineReducers } from 'redux'
import initial from './initial'
const handleDataAndError = (state, type, action) => {
if (action.target !== type) {
return state
}
switch (action.type) {
case 'SET_DATA':
return {
...state,
data: action.data[action.target],
}
default:
return state
}
}
const activities = (state = initial.activities, action) => {
switch (action.type) {
case 'PUSH_ACTIVITIES':
return {
...state,
data: state.data.concat(action.activities),
}
default:
return handleDataAndError(state, 'activities', action)
}
}
const calendarActivities = (state = initial.calendarActivities, action) => {
switch (action.type) {
case 'UPDATE_CALENDAR':
return {
...state,
data: action.activities,
}
default:
return handleDataAndError(state, 'calendarActivities', action)
}
}
const chartData = (state = initial.chartData, action) => {
switch (action.type) {
case 'SET_CHART_DATA':
return action.chartData
default:
return state
}
}
const formData = (state = initial.formData, action) => {
switch (action.type) {
case 'UPDATE_USER_FORMDATA':
return {
formData: {
...state.formData,
[action.target]: action.value
},
}
case 'PROFILE_SUCCESS':
case 'EMPTY_USER_FORMDATA':
return initial.formData
default:
return state
}
}
const formProfile = (state = initial.formProfile, action) => {
switch (action.type) {
case 'UPDATE_PROFILE_FORMDATA':
return {
formProfile: {
...state.formProfile,
[action.target]: action.value
},
}
case 'INIT_PROFILE_FORM':
return {
formProfile: {
...state.formProfile,
firstName: action.user.firstName,
lastName: action.user.lastName,
birthDate: action.user.birthDate,
location: action.user.location,
bio: action.user.bio,
},
}
case 'PROFILE_SUCCESS':
return initial.formProfile
default:
return state
}
}
const gpx = (state = initial.gpx, action) => {
switch (action.type) {
case 'SET_GPX':
return action.gpxContent
default:
return state
}
}
const loading = (state = initial.loading, action) => {
switch (action.type) {
case 'SET_LOADING':
return !state
default:
return state
}
}
const message = (state = initial.message, action) => {
switch (action.type) {
case 'AUTH_ERROR':
case 'PROFILE_ERROR':
case 'PROFILE_UPDATE_ERROR':
case 'PICTURE_ERROR':
case 'SET_ERROR':
return action.message
case 'LOGOUT':
case 'PROFILE_SUCCESS':
case 'SET_RESULTS':
case '@@router/LOCATION_CHANGE':
return ''
default:
return state
}
}
const messages = (state = initial.messages, action) => {
switch (action.type) {
case 'AUTH_ERRORS':
return action.messages
case 'LOGOUT':
case 'PROFILE_SUCCESS':
case '@@router/LOCATION_CHANGE':
return []
default:
return state
}
}
const records = (state = initial.records, action) =>
handleDataAndError(state, 'records', action)
const sports = (state = initial.sports, action) =>
handleDataAndError(state, 'sports', action)
const user = (state = initial.user, action) => {
switch (action.type) {
case 'AUTH_ERROR':
case 'PROFILE_ERROR':
case 'LOGOUT':
window.localStorage.removeItem('authToken')
return initial.user
case 'PROFILE_SUCCESS':
return {
id: action.message.data.id,
username: action.message.data.username,
email: action.message.data.email,
isAdmin: action.message.data.admin,
createdAt: action.message.data.created_at,
isAuthenticated: true,
firstName: action.message.data.first_name
? action.message.data.first_name
: '',
lastName: action.message.data.last_name
? action.message.data.last_name
: '',
bio: action.message.data.bio
? action.message.data.bio
: '',
location: action.message.data.location
? action.message.data.location
: '',
birthDate: action.message.data.birth_date
? format(new Date(action.message.data.birth_date),
'DD/MM/YYYY')
: '',
picture: action.message.data.picture === true
? action.message.data.picture
: false,
nbActivities: action.message.data.nb_activities,
nbSports: action.message.data.nb_sports,
totalDistance: action.message.data.total_distance,
totalDuration: action.message.data.total_duration,
}
default:
return state
}
}
const statistics = (state = initial.statistics, action) =>
handleDataAndError(state, 'statistics', action)
const reducers = combineReducers({
activities,
calendarActivities,
chartData,
formData,
formProfile,
gpx,
loading,
message,
messages,
records,
router: routerReducer,
sports,
statistics,
user,
})
export default reducers

View File

@ -0,0 +1,60 @@
const emptyData = {
data: [],
}
export default {
message: '',
messages: [],
user: {
id: '',
username: '',
email: '',
createdAt: '',
isAdmin: false,
isAuthenticated: false,
firstName: '',
lastName: '',
bio: '',
location: '',
birthDate: '',
picture: false
},
formData: {
formData: {
username: '',
email: '',
password: '',
passwordConf: '',
}
},
formProfile: {
formProfile: {
firstName: '',
lastName: '',
bio: '',
location: '',
birthDate: '',
password: '',
passwordConf: '',
}
},
activities: {
...emptyData,
},
calendarActivities: {
...emptyData,
},
chartData: [],
// check if storing gpx content is OK
gpx: null,
loading: false,
records: {
...emptyData,
},
sports: {
...emptyData,
},
statistics: {
data: {},
},
}

View File

@ -0,0 +1,113 @@
// In production, we register a service worker to serve assets from local cache.
// This lets the app load faster on subsequent visits in production, and gives
// it offline capabilities. However, it also means that developers (and users)
// will only see deployed updates on the "N+1" visit to a page, since previously
// cached resources are updated in the background.
// To learn more about the benefits of this model, read https://goo.gl/KwvDNy.
// This link also includes instructions on opting out of this behavior.
const isLocalhost = Boolean(
window.location.hostname === 'localhost' ||
// [::1] is the IPv6 localhost address.
window.location.hostname === '[::1]' ||
// 127.0.0.1/8 is considered localhost for IPv4.
window.location.hostname.match(
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
)
)
export default function register() {
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
// The URL constructor is available in all browsers that support SW.
const publicUrl = new URL(process.env.PUBLIC_URL, window.location)
if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different origin
// from what our page is served on. This might happen if a CDN is used to
// serve assets;
// see https://github.com/facebookincubator/create-react-app/issues/2374
return
}
window.addEventListener('load', () => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`
if (isLocalhost) {
// This is running on localhost.
// Lets check if a service worker still exists or not.
checkValidServiceWorker(swUrl)
} else {
// Is not local host. Just register service worker
registerValidSW(swUrl)
}
})
}
}
function registerValidSW(swUrl) {
navigator.serviceWorker
.register(swUrl)
.then(registration => {
registration.onupdatefound = () => {
const installingWorker = registration.installing
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// At this point, the old content will have been purged and
// the fresh content will have been added to the cache.
// It's the perfect time to display a "New content is
// available; please refresh." message in your web app.
// eslint-disable-next-line no-console
console.log('New content is available; please refresh.')
} else {
// At this point, everything has been precached.
// It's the perfect time to display a
// "Content is cached for offline use." message.
// eslint-disable-next-line no-console
console.log('Content is cached for offline use.')
}
}
}
}
})
.catch(error => {
console.error('Error during service worker registration:', error)
})
}
function checkValidServiceWorker(swUrl) {
// Check if the service worker can be found. If it can't reload the page.
fetch(swUrl)
.then(response => {
// Ensure service worker exists, and that we really are getting a JS file.
if (
response.status === 404 ||
response.headers.get('content-type').indexOf('javascript') === -1
) {
// No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then(registration => {
registration.unregister().then(() => {
window.location.reload()
})
})
} else {
// Service worker found. Proceed as normal.
registerValidSW(swUrl)
}
})
.catch(() => {
// eslint-disable-next-line no-console
console.log(
'No internet connection found. App is running in offline mode.'
)
})
}
export function unregister() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready.then(registration => {
registration.unregister()
})
}
}

View File

@ -0,0 +1,153 @@
import togeojson from '@mapbox/togeojson'
import { addDays, format, parse, startOfWeek, subHours } from 'date-fns'
export const apiUrl = `${process.env.REACT_APP_API_URL}/api/`
export const thunderforestApiKey = `${
process.env.REACT_APP_THUNDERFOREST_API_KEY
}`
export const activityColors = [
'#55a8a3',
'#98C3A9',
'#D0838A',
'#ECC77E',
'#926692',
'#929292',
'#428bca',
]
export const isLoggedIn = () => !!window.localStorage.authToken
export const generateIds = arr => {
let i = 0
return arr.map(val => {
const obj = { id: i, value: val }
i++
return obj
})
}
export const createRequest = params => {
const headers = {}
if (!params.noAuthorization) {
headers.Authorization = `Bearer ${
window.localStorage.getItem('authToken')}`
}
if (params.type) {
headers['Content-Type'] = params.type
}
const requestParams = {
method: params.method,
headers: headers,
}
if (params.type === 'application/json' && params.body) {
requestParams.body = JSON.stringify(params.body)
} else if (params.body) {
requestParams.body = params.body
}
const request = new Request(params.url, requestParams)
return fetch(request)
.then(response => params.method === 'DELETE'
? response
: response.json())
.catch(error => error)
}
export const getGeoJson = gpxContent => {
let jsonData
if (gpxContent) {
const gpx = new DOMParser().parseFromString(gpxContent, 'text/xml')
jsonData = togeojson.gpx(gpx)
}
return { jsonData }
}
export const formatActivityDate = activityDateTime => {
if (activityDateTime) {
const dateTime = parse(activityDateTime)
return {
activity_date: format(dateTime, 'DD/MM/YYYY'),
activity_time: activityDateTime.match(/[0-2][0-9]:[0-5][0-9]/)[0]
}
}
return {
activity_date: null,
activity_time: null,
}
}
export const formatRecord = record => {
let value, recordType = null
switch (record.record_type) {
case 'AS':
case 'MS':
value = `${record.value} km/h`
recordType = record.record_type === 'AS' ? 'Avg speed' : 'Max speed'
break
case 'FD':
value = `${record.value} km`
recordType = 'Farest distance'
break
default: // 'LD'
value = record.value // eslint-disable-line prefer-destructuring
recordType = 'Longest duration'
}
return {
activity_date: formatActivityDate(record.activity_date).activity_date,
activity_id: record.activity_id,
id: record.id,
record_type: recordType,
value: value,
}
}
const formatDuration = seconds => {
let newDate = new Date(0)
newDate = subHours(newDate.setSeconds(seconds), 1)
return newDate.getTime()
}
export const formatChartData = chartData => {
for (let i = 0; i < chartData.length; i++) {
chartData[i].time = new Date(chartData[i].time).getTime()
chartData[i].duration = formatDuration(chartData[i].duration)
}
return chartData
}
export const formatStats = (stats, sports, startDate, endDate) => {
const nbActivitiesStats = []
const distanceStats = []
const durationStats = []
for (let day = startOfWeek(startDate);
day <= endDate;
day = addDays(day, 7)
) {
const date = format(day, 'YYYY-MM-DD')
const xAxis = format(day, 'DD/MM')
const dataNbActivities = { date: xAxis }
const dataDistance = { date: xAxis }
const dataDuration = { date: xAxis }
if (stats[date]) {
Object.keys(stats[date]).map(sportId => {
const sportLabel = sports.filter(s => s.id === +sportId)[0].label
dataNbActivities[sportLabel] = stats[date][sportId].nb_activities
dataDistance[sportLabel] = stats[date][sportId].total_distance
dataDuration[sportLabel] = stats[date][sportId].total_duration
return null
})
}
nbActivitiesStats.push(dataNbActivities)
distanceStats.push(dataDistance)
durationStats.push(dataDuration)
}
return {
activities: nbActivitiesStats,
distance: distanceStats,
duration: durationStats
}
}