Client - init workout chart (WIP)

This commit is contained in:
Sam 2021-09-26 08:59:17 +02:00
parent 146899c269
commit c50e74143e
19 changed files with 467 additions and 37 deletions

View File

@ -13,6 +13,21 @@
</template>
<script lang="ts">
import {
Chart,
BarElement,
LineElement,
PointElement,
Legend,
Title,
Tooltip,
Filler,
BarController,
CategoryScale,
LineController,
LinearScale,
} from 'chart.js'
import ChartDataLabels from 'chartjs-plugin-datalabels'
import { computed, ComputedRef, defineComponent, onBeforeMount } from 'vue'
import Loader from '@/components/Common/Loader.vue'
@ -23,6 +38,21 @@
import { IAppConfig } from '@/types/application'
import { useStore } from '@/use/useStore'
Chart.register(
BarElement,
LineElement,
PointElement,
Legend,
Title,
Tooltip,
Filler,
BarController,
CategoryScale,
LineController,
LinearScale,
ChartDataLabels
)
export default defineComponent({
name: 'App',
components: {

View File

@ -5,25 +5,15 @@
</template>
<script lang="ts">
import {
Chart,
ChartData,
ChartOptions,
LayoutItem,
registerables,
} from 'chart.js'
import ChartDataLabels from 'chartjs-plugin-datalabels'
import { ChartData, ChartOptions, LayoutItem } from 'chart.js'
import { ComputedRef, PropType, computed, defineComponent } from 'vue'
import { BarChart, useBarChart } from 'vue-chart-3'
import { useI18n } from 'vue-i18n'
import { IChartDataset } from '@/types/chart'
import { TDatasetKeys } from '@/types/statistics'
import { TStatisticsDatasetKeys } from '@/types/statistics'
import { formatTooltipValue } from '@/utils/tooltip'
Chart.register(...registerables)
Chart.register(ChartDataLabels)
export default defineComponent({
name: 'Chart',
components: {
@ -39,7 +29,7 @@
required: true,
},
displayedData: {
type: String as PropType<TDatasetKeys>,
type: String as PropType<TStatisticsDatasetKeys>,
required: true,
},
},

View File

@ -16,7 +16,7 @@
import {
IStatisticsChartData,
IStatisticsDateParams,
TDatasetKeys,
TStatisticsDatasetKeys,
TStatisticsFromApi,
} from '@/types/statistics'
import { formatStats } from '@/utils/statistics'
@ -32,7 +32,7 @@
required: true,
},
displayedData: {
type: String as PropType<TDatasetKeys>,
type: String as PropType<TStatisticsDatasetKeys>,
required: true,
},
params: {

View File

@ -0,0 +1,188 @@
<template>
<div id="workout-chart">
<Card :without-title="false">
<template #title>{{ t('workouts.ANALYSIS') }} </template>
<template #content>
<div class="chart-radio">
<label>
<input
type="radio"
name="distance"
:checked="displayDistance"
@click="updateDisplayDistance"
/>
{{ t('workouts.DISTANCE') }}
</label>
<label>
<input
type="radio"
name="duration"
:checked="!displayDistance"
@click="updateDisplayDistance"
/>
{{ t('workouts.DURATION') }}
</label>
</div>
<LineChart v-bind="lineChartProps" class="line-chart" />
<div class="no-data-cleaning">
{{ t('workouts.NO_DATA_CLEANING') }}
</div>
</template>
</Card>
</div>
</template>
<script lang="ts">
import { ChartData, ChartOptions } from 'chart.js'
import { PropType, defineComponent, ref, ComputedRef, computed } from 'vue'
import { LineChart, useLineChart } from 'vue-chart-3'
import { useI18n } from 'vue-i18n'
import Card from '@/components/Common/Card.vue'
import { IAuthUserProfile } from '@/types/user'
import { IWorkoutChartData, IWorkoutState } from '@/types/workouts'
import { getDatasets } from '@/utils/workouts'
export default defineComponent({
name: 'WorkoutChart',
components: {
Card,
LineChart,
},
props: {
authUser: {
type: Object as PropType<IAuthUserProfile>,
required: true,
},
workout: {
type: Object as PropType<IWorkoutState>,
required: true,
},
},
setup(props) {
const { t } = useI18n()
let displayDistance = ref(true)
const datasets: ComputedRef<IWorkoutChartData> = computed(() =>
getDatasets(props.workout.chartData, t)
)
let chartData: ComputedRef<ChartData<'line'>> = computed(() => ({
labels: displayDistance.value
? datasets.value.distance_labels
: datasets.value.duration_labels,
datasets: JSON.parse(
JSON.stringify([
datasets.value.datasets.speed,
datasets.value.datasets.elevation,
])
),
}))
const options = computed<ChartOptions<'line'>>(() => ({
responsive: true,
animation: false,
layout: {
padding: {
top: 22,
},
},
scales: {
x: {
grid: {
drawOnChartArea: false,
},
ticks: {
count: 10,
callback: function (value) {
return displayDistance.value
? Number(value).toFixed(2)
: new Date(+value * 1000).toISOString().substr(11, 8)
},
},
type: 'linear',
bounds: 'data',
title: {
display: true,
text: displayDistance.value
? t('workouts.DISTANCE') + ' (km)'
: t('workouts.DURATION'),
},
},
ySpeed: {
grid: {
drawOnChartArea: false,
},
position: 'left',
title: {
display: true,
text: t('workouts.SPEED') + ' (km/h)',
},
},
yElevation: {
beginAtZero: true,
grid: {
drawOnChartArea: false,
},
position: 'right',
title: {
display: true,
text: t('workouts.ELEVATION') + ' (m)',
},
},
},
elements: {
point: {
pointStyle: 'circle',
pointRadius: 0,
},
},
plugins: {
datalabels: {
display: false,
},
},
}))
function updateDisplayDistance() {
displayDistance.value = !displayDistance.value
}
const { lineChartProps } = useLineChart({
chartData,
options,
})
return {
displayDistance,
lineChartProps,
t,
updateDisplayDistance,
}
},
})
</script>
<style lang="scss" scoped>
@import '~@/scss/base';
#workout-chart {
::v-deep(.card) {
.card-title {
text-transform: capitalize;
}
.card-content {
display: flex;
flex-direction: column;
.chart-radio {
width: 100%;
display: flex;
justify-content: center;
label {
padding: 0 $default-padding;
}
}
.no-data-cleaning {
font-size: 0.85em;
font-style: italic;
}
}
}
}
</style>

View File

@ -1,10 +1,12 @@
{
"ADD_WORKOUT": "Add workout",
"ANALYSIS": "analysis",
"ASCENT": "ascent",
"AVERAGE_SPEED": "average speed",
"DESCENT": "descent",
"DISTANCE": "distance",
"DURATION": "duration",
"ELEVATION": "elevation",
"END": "end",
"KM": "km",
"LATEST_WORKOUTS": "Latest workouts",
@ -12,6 +14,7 @@
"MAX_SPEED": "max. speed",
"MIN_ALTITUDE": "min. altitude",
"NEXT_WORKOUT": "Next workout",
"NO_DATA_CLEANING": "data from gpx, without any cleaning",
"NO_MAP": "No map",
"NO_NEXT_WORKOUT": "No next workout",
"NO_PREVIOUS_WORKOUT": "No previous workout",
@ -24,6 +27,7 @@
"RECORD_FD": "Farest distance",
"RECORD_LD": "Longest duration",
"RECORD_MS": "Max. speed",
"SPEED": "speed",
"SPORT": "sport | sports",
"START": "start",
"TOTAL_DURATION": "total duration",

View File

@ -1,10 +1,12 @@
{
"ADD_WORKOUT": "Ajouter une séance",
"ANALYSIS": "analyse",
"ASCENT": "dénivelé positif",
"AVERAGE_SPEED": "vitesse moyenne",
"DESCENT": "dénivelé négatif",
"DISTANCE": "distance",
"DURATION": "durée",
"ELEVATION": "altitude",
"END": "fin",
"KM": "km",
"LATEST_WORKOUTS": "Séances récentes",
@ -12,6 +14,7 @@
"MAX_SPEED": "vitesse max",
"MIN_ALTITUDE": "altitude min",
"NEXT_WORKOUT": "Séance suivante",
"NO_DATA_CLEANING": "données issues du fichier gpx, sans correction",
"NO_MAP": "Pas de carte",
"NO_NEXT_WORKOUT": "Pas de séance suivante",
"NO_PREVIOUS_WORKOUT": "Pas de séances précédente",
@ -24,6 +27,7 @@
"RECORD_FD": "Distance la + longue",
"RECORD_LD": "Durée la + longue",
"RECORD_MS": "Vitesse max.",
"SPEED": "vitesse",
"SPORT": "sport | sports",
"START": "début",
"TOTAL_DURATION": "durée totale",

View File

@ -64,6 +64,14 @@ export const actions: ActionTree<IWorkoutsState, IRootState> &
res.data.data.workouts[0]
)
if (res.data.data.workouts[0].with_gpx) {
authApi.get(`workouts/${workoutId}/chart_data`).then((res) => {
if (res.data.status === 'success') {
context.commit(
WORKOUTS_STORE.MUTATIONS.SET_WORKOUT_CHART_DATA,
res.data.data.chart_data
)
}
})
authApi.get(`workouts/${workoutId}/gpx`).then((res) => {
if (res.data.status === 'success') {
context.commit(

View File

@ -17,5 +17,6 @@ export enum WorkoutsMutations {
SET_USER_WORKOUTS = 'SET_USER_WORKOUTS',
SET_WORKOUT = 'SET_WORKOUT',
SET_WORKOUT_GPX = 'SET_WORKOUT_GPX',
SET_WORKOUT_CHART_DATA = 'SET_WORKOUT_CHART_DATA',
SET_WORKOUT_LOADING = 'SET_WORKOUT_LOADING',
}

View File

@ -1,11 +1,12 @@
import { MutationTree } from 'vuex'
import { WORKOUTS_STORE } from '@/store/constants'
import { initialWorkoutValue } from '@/store/modules/workouts/state'
import {
IWorkoutsState,
TWorkoutsMutations,
} from '@/store/modules/workouts/types'
import { IWorkout } from '@/types/workouts'
import { IWorkout, IWorkoutApiChartData } from '@/types/workouts'
export const mutations: MutationTree<IWorkoutsState> & TWorkoutsMutations = {
[WORKOUTS_STORE.MUTATIONS.SET_CALENDAR_WORKOUTS](
@ -26,11 +27,11 @@ export const mutations: MutationTree<IWorkoutsState> & TWorkoutsMutations = {
) {
state.workout.workout = workout
},
[WORKOUTS_STORE.MUTATIONS.SET_WORKOUT_LOADING](
[WORKOUTS_STORE.MUTATIONS.SET_WORKOUT_CHART_DATA](
state: IWorkoutsState,
loading: boolean
chartData: IWorkoutApiChartData[]
) {
state.workout.loading = loading
state.workout.chartData = chartData
},
[WORKOUTS_STORE.MUTATIONS.SET_WORKOUT_GPX](
state: IWorkoutsState,
@ -38,15 +39,17 @@ export const mutations: MutationTree<IWorkoutsState> & TWorkoutsMutations = {
) {
state.workout.gpx = gpx
},
[WORKOUTS_STORE.MUTATIONS.SET_WORKOUT_LOADING](
state: IWorkoutsState,
loading: boolean
) {
state.workout.loading = loading
},
[WORKOUTS_STORE.MUTATIONS.EMPTY_WORKOUTS](state: IWorkoutsState) {
state.calendar_workouts = []
state.user_workouts = []
},
[WORKOUTS_STORE.MUTATIONS.EMPTY_WORKOUT](state: IWorkoutsState) {
state.workout = {
gpx: '',
loading: false,
workout: <IWorkout>{},
}
state.workout = initialWorkoutValue
},
}

View File

@ -1,12 +1,15 @@
import { IWorkoutsState } from '@/store/modules/workouts/types'
import { IWorkout } from '@/types/workouts'
export const initialWorkoutValue = {
gpx: '',
loading: false,
workout: <IWorkout>{},
chartData: [],
}
export const workoutsState: IWorkoutsState = {
calendar_workouts: [],
user_workouts: [],
workout: {
gpx: '',
loading: false,
workout: <IWorkout>{},
},
workout: initialWorkoutValue,
}

View File

@ -7,7 +7,12 @@ import {
import { WORKOUTS_STORE } from '@/store/constants'
import { IRootState } from '@/store/modules/root/types'
import { IWorkout, IWorkoutsPayload, IWorkoutState } from '@/types/workouts'
import {
IWorkout,
IWorkoutApiChartData,
IWorkoutsPayload,
IWorkoutState,
} from '@/types/workouts'
export interface IWorkoutsState {
user_workouts: IWorkout[]
@ -46,6 +51,10 @@ export type TWorkoutsMutations<S = IWorkoutsState> = {
workouts: IWorkout[]
): void
[WORKOUTS_STORE.MUTATIONS.SET_WORKOUT](state: S, workout: IWorkout): void
[WORKOUTS_STORE.MUTATIONS.SET_WORKOUT_CHART_DATA](
state: S,
chartDate: IWorkoutApiChartData[]
): void
[WORKOUTS_STORE.MUTATIONS.SET_WORKOUT_GPX](state: S, gpx: string): void
[WORKOUTS_STORE.MUTATIONS.SET_WORKOUT_LOADING](
state: S,

View File

@ -1,5 +1,9 @@
export interface IChartDataset {
label: string
backgroundColor: string[]
borderColor?: string[]
borderWidth?: number
fill?: boolean
data: number[]
yAxisID?: string
}

View File

@ -18,10 +18,13 @@ export interface IStatisticsDateParams {
end: Date
}
export type TDatasetKeys = 'nb_workouts' | 'total_duration' | 'total_distance'
export type TStatisticsDatasetKeys =
| 'nb_workouts'
| 'total_duration'
| 'total_distance'
export type TStatistics = {
[key in TDatasetKeys]: number
[key in TStatisticsDatasetKeys]: number
}
export type TSportStatistics = {
@ -33,7 +36,7 @@ export type TStatisticsFromApi = {
}
export type TStatisticsDatasets = {
[key in TDatasetKeys]: IChartDataset[]
[key in TStatisticsDatasetKeys]: IChartDataset[]
}
export interface IStatisticsChartData {

View File

@ -1,3 +1,5 @@
import { IChartDataset } from '@/types/chart'
export interface IWorkoutSegment {
ascent: number
ave_speed: number
@ -79,8 +81,31 @@ export interface IWorkoutsPayload {
page?: number
}
export interface IWorkoutApiChartData {
distance: number
duration: number
elevation: number
latitude: number
longitude: number
speed: number
time: string
}
export interface IWorkoutState {
gpx: string
loading: boolean
workout: IWorkout
chartData: IWorkoutApiChartData[]
}
export type TWorkoutDatasetKeys = 'speed' | 'elevation'
export type TWorkoutDatasets = {
[key in TWorkoutDatasetKeys]: IChartDataset
}
export interface IWorkoutChartData {
distance_labels: unknown[]
duration_labels: unknown[]
datasets: TWorkoutDatasets
}

View File

@ -5,7 +5,7 @@ import { ISport } from '@/types/sports'
import {
IStatisticsChartData,
IStatisticsDateParams,
TDatasetKeys,
TStatisticsDatasetKeys,
TStatisticsDatasets,
TStatisticsFromApi,
} from '@/types/statistics'
@ -19,7 +19,7 @@ const dateFormats: Record<string, string> = {
year: 'yyyy',
}
export const datasetKeys: TDatasetKeys[] = [
export const datasetKeys: TStatisticsDatasetKeys[] = [
'nb_workouts',
'total_duration',
'total_distance',

View File

@ -1,8 +1,8 @@
import { TDatasetKeys } from '@/types/statistics'
import { TStatisticsDatasetKeys } from '@/types/statistics'
import { formatDuration } from '@/utils/duration'
export const formatTooltipValue = (
displayedData: TDatasetKeys,
displayedData: TStatisticsDatasetKeys,
value: number,
formatWithUnits = true
): string => {

View File

@ -0,0 +1,41 @@
import {
IWorkoutApiChartData,
IWorkoutChartData,
TWorkoutDatasets,
} from '@/types/workouts'
export const getDatasets = (
chartData: IWorkoutApiChartData[],
t: CallableFunction
): IWorkoutChartData => {
const datasets: TWorkoutDatasets = {
speed: {
label: t('workouts.SPEED'),
backgroundColor: ['#FFFFFF'],
borderColor: ['#8884d8'],
borderWidth: 2,
data: [],
yAxisID: 'ySpeed',
},
elevation: {
label: t('workouts.ELEVATION'),
backgroundColor: ['#e5e5e5'],
borderColor: ['#cccccc'],
borderWidth: 1,
fill: true,
data: [],
yAxisID: 'yElevation',
},
}
const distance_labels: unknown[] = []
const duration_labels: unknown[] = []
chartData.map((data) => {
distance_labels.push(data.distance)
duration_labels.push(data.duration)
datasets.speed.data.push(data.speed)
datasets.elevation.data.push(data.elevation)
})
return { distance_labels, duration_labels, datasets }
}

View File

@ -14,6 +14,11 @@
:sports="sports"
:authUser="authUser"
/>
<WorkoutChart
v-if="workout.chartData.length > 0"
:workout="workout"
:authUser="authUser"
/>
</div>
<div v-else>
<NotFound target="WORKOUT" />
@ -35,6 +40,7 @@
import Loader from '@/components/Common/Loader.vue'
import NotFound from '@/components/Common/NotFound.vue'
import WorkoutChart from '@/components/Workout/WorkoutChart/index.vue'
import WorkoutDetail from '@/components/Workout/WorkoutDetail/index.vue'
import { SPORTS_STORE, USER_STORE, WORKOUTS_STORE } from '@/store/constants'
import { IAuthUserProfile } from '@/types/user'
@ -46,6 +52,7 @@
components: {
Loader,
NotFound,
WorkoutChart,
WorkoutDetail,
},
setup() {
@ -78,6 +85,7 @@
@import '~@/scss/base';
#workout {
display: flex;
margin-bottom: 45px;
.container {
width: 100%;
padding: 0;

View File

@ -0,0 +1,109 @@
import { assert } from 'chai'
import createI18n from '@/i18n'
import { getDatasets } from '@/utils/workouts'
const { t, locale } = createI18n.global
describe('getDatasets', () => {
const testparams = [
{
description:
'returns empty datasets when no chart data w/ french translation',
inputParams: {
charData: [],
locale: 'fr',
},
expected: {
distance_labels: [],
duration_labels: [],
datasets: {
speed: {
label: 'vitesse',
backgroundColor: ['#FFFFFF'],
borderColor: ['#8884d8'],
borderWidth: 2,
data: [],
yAxisID: 'ySpeed',
},
elevation: {
label: 'altitude',
backgroundColor: ['#e5e5e5'],
borderColor: ['#cccccc'],
borderWidth: 1,
fill: true,
data: [],
yAxisID: 'yElevation',
},
},
},
},
{
description: 'returns datasets w/ english translation',
inputParams: {
charData: [
{
distance: 0,
duration: 0,
elevation: 83.6,
latitude: 48.845574,
longitude: 2.373723,
speed: 2.89,
time: 'Sun, 12 Sep 2021 13:29:24 GMT',
},
{
distance: 0,
duration: 1,
elevation: 83.7,
latitude: 48.845578,
longitude: 2.373732,
speed: 1.56,
time: 'Sun, 12 Sep 2021 13:29:25 GMT',
},
{
distance: 0.01,
duration: 96,
elevation: 84.3,
latitude: 48.845591,
longitude: 2.373811,
speed: 14.73,
time: 'Sun, 12 Sep 2021 13:31:00 GMT',
},
],
locale: 'en',
},
expected: {
distance_labels: [0, 0, 0.01],
duration_labels: [0, 1, 96],
datasets: {
speed: {
label: 'speed',
backgroundColor: ['#FFFFFF'],
borderColor: ['#8884d8'],
borderWidth: 2,
data: [2.89, 1.56, 14.73],
yAxisID: 'ySpeed',
},
elevation: {
label: 'elevation',
backgroundColor: ['#e5e5e5'],
borderColor: ['#cccccc'],
borderWidth: 1,
fill: true,
data: [83.6, 83.7, 84.3],
yAxisID: 'yElevation',
},
},
},
},
]
testparams.map((testParams) => {
it(testParams.description, () => {
locale.value = testParams.inputParams.locale
assert.deepEqual(
getDatasets(testParams.inputParams.charData, t),
testParams.expected
)
})
})
})