Client - add full statistics chart

This commit is contained in:
Sam 2021-10-03 19:23:17 +02:00
parent 60a5df70a9
commit 1502e97211
12 changed files with 579 additions and 250 deletions

View File

@ -32,6 +32,14 @@
type: String as PropType<TStatisticsDatasetKeys>,
required: true,
},
displayedSportIds: {
type: Array as PropType<number[]>,
required: true,
},
fullStats: {
type: Boolean,
required: true,
},
},
setup(props) {
const { t } = useI18n()
@ -54,7 +62,7 @@
animation: false,
layout: {
padding: {
top: 22,
top: props.fullStats ? 40 : 22,
},
},
scales: {
@ -76,7 +84,7 @@
},
},
afterFit: function (scale: LayoutItem) {
scale.width = 60
scale.width = props.fullStats ? 75 : 60
},
},
},
@ -84,13 +92,22 @@
datalabels: {
anchor: 'end',
align: 'end',
rotation: function (context) {
return props.fullStats && context.chart.chartArea.width < 580
? 310
: 0
},
display: function (context) {
return !(props.fullStats && context.chart.chartArea.width < 300)
},
formatter: function (value, context) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const total: number = context.chart.data.datasets
.map((d) => d.data[context.dataIndex])
.reduce((total, value) => getSum(total, value), 0)
return context.datasetIndex === 5 && total > 0
return context.datasetIndex ===
props.displayedSportIds.length - 1 && total > 0
? formatTooltipValue(props.displayedData, total, false)
: null
},

View File

@ -1,68 +1,170 @@
<template>
<div class="stat-chart">
<Chart
:datasets="datasets"
:labels="labels"
:displayedData="displayedData"
/>
<div class="start-chart">
<div v-if="labels.length === 0">
{{ t('workouts.NO_WORKOUTS') }}
</div>
<div v-else>
<div class="chart-radio">
<label>
<input
type="radio"
name="total_distance"
:checked="displayedData === 'total_distance'"
@click="updateDisplayData"
/>
{{ t('workouts.DISTANCE') }}
</label>
<label>
<input
type="radio"
name="total_duration"
:checked="displayedData === 'total_duration'"
@click="updateDisplayData"
/>
{{ t('workouts.DURATION') }}
</label>
<label>
<input
type="radio"
name="nb_workouts"
:checked="displayedData === 'nb_workouts'"
@click="updateDisplayData"
/>
{{ t('workouts.WORKOUT', 2) }}
</label>
</div>
<Chart
v-if="labels.length > 0"
:datasets="datasets"
:labels="labels"
:displayedData="displayedData"
:displayedSportIds="displayedSportIds"
:fullStats="fullStats"
/>
</div>
</div>
</template>
<script lang="ts">
import { ComputedRef, PropType, computed, defineComponent } from 'vue'
import { format } from 'date-fns'
import {
ComputedRef,
PropType,
Ref,
computed,
defineComponent,
ref,
watch,
onBeforeMount,
} from 'vue'
import { useI18n } from 'vue-i18n'
import Chart from '@/components/Common/StatsChart/Chart.vue'
import { STATS_STORE } from '@/store/constants'
import { ISport } from '@/types/sports'
import {
IStatisticsChartData,
IStatisticsDateParams,
TStatisticsDatasetKeys,
IStatisticsDateParams,
TStatisticsFromApi,
IStatisticsParams,
} from '@/types/statistics'
import { IAuthUserProfile } from '@/types/user'
import { useStore } from '@/use/useStore'
import { formatStats } from '@/utils/statistics'
export default defineComponent({
name: 'StatsChart',
name: 'UserMonthStats',
components: {
Chart,
},
props: {
statistics: {
type: Object as PropType<TStatisticsFromApi>,
required: true,
},
displayedData: {
type: String as PropType<TStatisticsDatasetKeys>,
required: true,
},
params: {
type: Object as PropType<IStatisticsDateParams>,
required: true,
},
sports: {
type: Object as PropType<ISport[]>,
required: true,
},
weekStartingMonday: {
type: Boolean,
user: {
type: Object as PropType<IAuthUserProfile>,
required: true,
},
chartParams: {
type: Object as PropType<IStatisticsDateParams>,
required: true,
},
displayedSportIds: {
type: Array as PropType<number[]>,
default: () => [],
},
fullStats: {
type: Boolean,
default: false,
},
},
setup(props) {
const store = useStore()
const { t } = useI18n()
let displayedData: Ref<TStatisticsDatasetKeys> = ref('total_distance')
const statistics: ComputedRef<TStatisticsFromApi> = computed(
() => store.getters[STATS_STORE.GETTERS.USER_STATS]
)
const formattedStats: ComputedRef<IStatisticsChartData> = computed(() =>
formatStats(
props.params,
props.weekStartingMonday,
props.chartParams,
props.user.weekm,
props.sports,
[],
props.statistics
props.displayedSportIds,
statistics.value
)
)
onBeforeMount(() =>
getStatistics(getApiParams(props.chartParams, props.user))
)
function getStatistics(apiParams: IStatisticsParams) {
store.dispatch(STATS_STORE.ACTIONS.GET_USER_STATS, {
username: props.user.username,
filterType: 'by_time',
params: apiParams,
})
}
function updateDisplayData(
event: Event & {
target: HTMLInputElement & { name: TStatisticsDatasetKeys }
}
) {
displayedData.value = event.target.name
}
function getApiParams(
chartParams: IStatisticsDateParams,
user: IAuthUserProfile
): IStatisticsParams {
return {
from: format(chartParams.start, 'yyyy-MM-dd'),
to: format(chartParams.end, 'yyyy-MM-dd'),
time:
chartParams.duration === 'week'
? `week${user.weekm ? 'm' : ''}`
: chartParams.duration,
}
}
watch(
() => props.chartParams,
async (newParams) => {
getStatistics(getApiParams(newParams, props.user))
}
)
return {
datasets: computed(
() => formattedStats.value.datasets[props.displayedData]
() => formattedStats.value.datasets[displayedData.value]
),
labels: computed(() => formattedStats.value.labels),
displayedData,
t,
updateDisplayData,
}
},
})
@ -70,10 +172,16 @@
<style lang="scss" scoped>
@import '~@/scss/base';
.stat-chart {
.chart {
.start-chart {
.chart-radio {
display: flex;
justify-content: space-between;
padding: $default-padding;
max-height: 100%;
label {
font-size: 0.85em;
font-weight: normal;
}
}
}
</style>

View File

@ -3,78 +3,32 @@
<Card>
<template #title>{{ $t('dashboard.THIS_MONTH') }}</template>
<template #content>
<div v-if="Object.keys(statistics).length === 0">
{{ t('workouts.NO_WORKOUTS') }}
</div>
<div v-else>
<div class="chart-radio">
<label class="">
<input
type="radio"
name="total_distance"
:checked="displayedData === 'total_distance'"
@click="updateDisplayData"
/>
{{ t('workouts.DISTANCE') }}
</label>
<label class="">
<input
type="radio"
name="total_duration"
:checked="displayedData === 'total_duration'"
@click="updateDisplayData"
/>
{{ t('workouts.DURATION') }}
</label>
<label class="">
<input
type="radio"
name="nb_workouts"
:checked="displayedData === 'nb_workouts'"
@click="updateDisplayData"
/>
{{ t('workouts.WORKOUT', 2) }}
</label>
</div>
<Chart
:displayedData="displayedData"
:params="chartParams"
:statistics="statistics"
:sports="sports"
:week-starting-monday="weekStartingMonday"
v-if="statistics && weekStartingMonday !== undefined"
/>
</div>
<StatChart
:sports="sports"
:user="user"
:chart-params="chartParams"
:displayed-sport-ids="selectedSportIds"
/>
</template>
</Card>
</div>
</template>
<script lang="ts">
import { endOfMonth, format, startOfMonth } from 'date-fns'
import {
ComputedRef,
PropType,
computed,
defineComponent,
ref,
onBeforeMount,
} from 'vue'
import { endOfMonth, startOfMonth } from 'date-fns'
import { PropType, defineComponent } from 'vue'
import { useI18n } from 'vue-i18n'
import Card from '@/components/Common/Card.vue'
import Chart from '@/components/Common/StatsChart/index.vue'
import { STATS_STORE } from '@/store/constants'
import StatChart from '@/components/Common/StatsChart/index.vue'
import { ISport } from '@/types/sports'
import { IStatisticsDateParams, TStatisticsFromApi } from '@/types/statistics'
import { IAuthUserProfile } from '@/types/user'
import { useStore } from '@/use/useStore'
export default defineComponent({
name: 'UserMonthStats',
components: {
Card,
Chart,
StatChart,
},
props: {
sports: {
@ -87,46 +41,16 @@
},
},
setup(props) {
const store = useStore()
const { t } = useI18n()
onBeforeMount(() => getStatistics())
const date = new Date()
const dateFormat = 'yyyy-MM-dd'
let displayedData = ref('total_distance')
const chartParams: IStatisticsDateParams = {
duration: 'week',
start: startOfMonth(date),
end: endOfMonth(date),
}
const apiParams = {
from: format(chartParams.start, dateFormat),
to: format(chartParams.end, dateFormat),
time: `week${props.user.weekm ? 'm' : ''}`,
}
const statistics: ComputedRef<TStatisticsFromApi> = computed(
() => store.getters[STATS_STORE.GETTERS.USER_STATS]
)
function updateDisplayData(event: Event & { target: HTMLInputElement }) {
displayedData.value = event.target.name
}
function getStatistics() {
store.dispatch(STATS_STORE.ACTIONS.GET_USER_STATS, {
username: props.user.username,
filterType: 'by_time',
params: apiParams,
})
}
return {
weekStartingMonday: computed<boolean>(() => props.user.weekm),
chartParams,
displayedData,
statistics,
chartParams: {
duration: 'week',
start: startOfMonth(date),
end: endOfMonth(date),
},
selectedSportIds: props.sports.map((sport) => sport.id),
t,
updateDisplayData,
}
},
})
@ -138,23 +62,6 @@
.user-month-stats {
::v-deep(.card-content) {
padding: $default-padding;
.stat-chart {
.chart {
.bar-chart {
height: 100%;
}
}
}
.chart-radio {
display: flex;
justify-content: space-between;
padding: $default-padding;
label {
font-size: 0.85em;
font-weight: normal;
}
}
}
}
</style>

View File

@ -26,7 +26,9 @@
<div class="nav-item">
{{ capitalize(t('workouts.WORKOUT', 2)) }}
</div>
<div class="nav-item">{{ t('statistics.STATISTICS') }}</div>
<router-link class="nav-item" to="/statistics">
{{ t('statistics.STATISTICS') }}
</router-link>
<div class="nav-item">{{ t('administration.ADMIN') }}</div>
<router-link class="nav-item" to="/workouts/add">
{{ t('workouts.ADD_WORKOUT') }}

View File

@ -0,0 +1,330 @@
<template>
<div id="user-statistics">
<Card v-if="translatedSports">
<template #title>{{ $t('statistics.STATISTICS') }}</template>
<template #content>
<div class="chart-filters">
<div class="chart-arrow">
<i
class="fa fa-chevron-left"
aria-hidden="true"
@click="handleOnClickArrows(true)"
/>
</div>
<div class="time-frames">
<div class="time-frames-checkboxes">
<div v-for="frame in timeFrames" class="time-frame" :key="frame">
<label>
<input
type="radio"
:id="frame"
:name="frame"
:checked="selectedTimeFrame === frame"
@input="updateTimeFrame(frame)"
/>
<span>{{ t(`statistics.TIME_FRAMES.${frame}`) }}</span>
</label>
</div>
</div>
</div>
<div class="chart-arrow">
<i
class="fa fa-chevron-right"
aria-hidden="true"
@click="handleOnClickArrows(false)"
/>
</div>
</div>
<StatChart
:sports="sports"
:user="user"
:chartParams="chartParams"
:displayed-sport-ids="selectedSportIds"
:fullStats="true"
/>
<div class="sports">
<label
v-for="sport in translatedSports"
type="checkbox"
:key="sport.id"
:style="{ color: sportColors[sport.label] }"
>
<input
type="checkbox"
:id="sport.id"
:name="sport.label"
:checked="selectedSportIds.includes(sport.id)"
@input="updateSelectedSportIds(sport.id)"
/>
<SportImage :sport-label="sport.label" />
{{ sport.translatedLabel }}
</label>
</div>
</template>
</Card>
</div>
</template>
<script lang="ts">
import {
addMonths,
addWeeks,
addYears,
endOfMonth,
endOfWeek,
endOfYear,
startOfMonth,
startOfWeek,
startOfYear,
subMonths,
subWeeks,
subYears,
} from 'date-fns'
import {
ComputedRef,
PropType,
Ref,
computed,
defineComponent,
ref,
watch,
} from 'vue'
import { useI18n } from 'vue-i18n'
import Card from '@/components/Common/Card.vue'
import SportImage from '@/components/Common/SportImage/index.vue'
import StatChart from '@/components/Common/StatsChart/index.vue'
import { ISport, ITranslatedSport } from '@/types/sports'
import { IStatisticsDateParams } from '@/types/statistics'
import { IAuthUserProfile } from '@/types/user'
import { translateSports, sportColors } from '@/utils/sports'
export default defineComponent({
name: 'UserMonthStats',
components: {
Card,
SportImage,
StatChart,
},
props: {
sports: {
type: Object as PropType<ISport[]>,
required: true,
},
user: {
type: Object as PropType<IAuthUserProfile>,
required: true,
},
},
setup(props) {
const { t } = useI18n()
let selectedTimeFrame = ref('month')
const timeFrames = ['week', 'month', 'year']
const chartParams: Ref<IStatisticsDateParams> = ref(
getChartParams(selectedTimeFrame.value)
)
const translatedSports: ComputedRef<ITranslatedSport[]> = computed(() =>
translateSports(props.sports, t)
)
const selectedSportIds: Ref<number[]> = ref(getSports(props.sports))
function updateTimeFrame(timeFrame: string) {
selectedTimeFrame.value = timeFrame
chartParams.value = getChartParams(selectedTimeFrame.value)
}
function getChartParams(timeFrame: string): IStatisticsDateParams {
const date = new Date()
const start =
timeFrame === 'year'
? startOfYear(subYears(date, 9))
: selectedTimeFrame.value === 'week'
? startOfMonth(subMonths(date, 2))
: startOfMonth(subMonths(date, 11))
const end =
timeFrame === 'year'
? endOfYear(date)
: timeFrame === 'week'
? endOfWeek(date)
: endOfMonth(date)
return {
duration: timeFrame,
end,
start,
}
}
function handleOnClickArrows(backward: boolean) {
chartParams.value = {
duration: selectedTimeFrame.value,
end:
selectedTimeFrame.value === 'year'
? startOfYear(
backward
? endOfYear(subYears(chartParams.value.end, 1))
: endOfYear(addYears(chartParams.value.end, 1))
)
: selectedTimeFrame.value === 'week'
? startOfMonth(
backward
? endOfWeek(subWeeks(chartParams.value.end, 1))
: endOfWeek(addWeeks(chartParams.value.end, 1))
)
: startOfMonth(
backward
? endOfMonth(subMonths(chartParams.value.end, 1))
: endOfMonth(addMonths(chartParams.value.end, 1))
),
start:
selectedTimeFrame.value === 'year'
? startOfYear(
backward
? startOfYear(subYears(chartParams.value.start, 1))
: startOfYear(addYears(chartParams.value.start, 1))
)
: selectedTimeFrame.value === 'week'
? startOfMonth(
backward
? startOfWeek(subWeeks(chartParams.value.start, 1))
: startOfWeek(addWeeks(chartParams.value.start, 1))
)
: startOfMonth(
backward
? startOfMonth(subMonths(chartParams.value.start, 1))
: startOfMonth(addMonths(chartParams.value.start, 1))
),
}
}
function getSports(sports: ISport[]) {
return sports.map((sport) => sport.id)
}
function updateSelectedSportIds(sportId: number) {
if (selectedSportIds.value.includes(sportId)) {
selectedSportIds.value = selectedSportIds.value.filter(
(id) => id !== sportId
)
} else {
selectedSportIds.value.push(sportId)
}
}
watch(
() => props.sports,
(newSports) => {
selectedSportIds.value = getSports(newSports)
}
)
return {
chartParams,
selectedTimeFrame,
sportColors,
t,
timeFrames,
translatedSports,
selectedSportIds,
handleOnClickArrows,
updateSelectedSportIds,
updateTimeFrame,
}
},
})
</script>
<style lang="scss" scoped>
@import '~@/scss/base';
#user-statistics {
display: flex;
width: 100%;
margin-bottom: 30px;
::v-deep(.card) {
width: 100%;
.card-content {
.chart-filters {
display: flex;
.chart-arrow,
.time-frames {
flex-grow: 1;
text-align: center;
}
.chart-arrow {
cursor: pointer;
}
.time-frames {
display: flex;
justify-content: space-around;
.time-frames-checkboxes {
display: inline-flex;
.time-frame {
label {
font-weight: normal;
float: left;
padding: 0 5px;
cursor: pointer;
}
label input {
display: none;
}
label span {
border: solid 1px var(--time-frame-border-color);
border-radius: 9%;
display: block;
font-size: 0.9em;
padding: 2px 6px;
text-align: center;
}
input:checked + span {
background-color: var(--time-frame-checked-bg-color);
color: var(--time-frame-checked-color);
}
}
}
}
}
.chart-radio {
justify-content: space-around;
padding: $default-padding * 2 $default-padding;
}
.sports {
display: flex;
justify-content: space-between;
padding: $default-padding * 2 $default-padding;
@media screen and (max-width: $medium-limit) {
justify-content: normal;
flex-wrap: wrap;
}
label {
display: flex;
align-items: center;
font-size: 0.9em;
font-weight: normal;
min-width: 120px;
padding: $default-padding;
@media screen and (max-width: $medium-limit) {
min-width: 100px;
}
@media screen and (max-width: $x-small-limit) {
width: 100%;
}
}
.sport-img {
padding: 3px;
width: 20px;
height: 20px;
}
}
}
}
}
</style>

View File

@ -1,4 +1,9 @@
{
"STATISTICS": "Statistics",
"TOTAL": "Total"
"TOTAL": "Total",
"TIME_FRAMES": {
"week": "week",
"month": "month",
"year": "year"
}
}

View File

@ -1,4 +1,9 @@
{
"STATISTICS": "Statistiques",
"TOTAL": "Total"
"TOTAL": "Total",
"TIME_FRAMES": {
"week": "semaine",
"month": "mois",
"year": "année"
}
}

View File

@ -7,6 +7,7 @@ import Dashboard from '@/views/DashBoard.vue'
import EditWorkout from '@/views/EditWorkout.vue'
import LoginOrRegister from '@/views/LoginOrRegister.vue'
import NotFoundView from '@/views/NotFoundView.vue'
import StatisticsView from '@/views/StatisticsView.vue'
import Workout from '@/views/Workout.vue'
const routes: Array<RouteRecordRaw> = [
@ -27,6 +28,11 @@ const routes: Array<RouteRecordRaw> = [
component: LoginOrRegister,
props: { action: 'register' },
},
{
path: '/statistics',
name: 'Statistics',
component: StatisticsView,
},
{
path: '/workouts/:workoutId',
name: 'Workout',

View File

@ -16,6 +16,10 @@
--card-border-color: #c4c7cf;
--input-border-color: #9da3af;
--time-frame-border-color: #9da3af;
--time-frame-checked-bg-color: #9da3af;
--time-frame-checked-color: #FFFFFF;
--calendar-border-color: #c4c7cf;
--calendar-week-end-color: #f5f5f5;
--calendar-today-color: #eff1f3;

View File

@ -76,7 +76,7 @@ export const formatStats = (
const dayKeys = getDateKeys(params, weekStartingMonday)
const dateFormat = dateFormats[params.duration]
const displayedSports = sports.filter((sport) =>
displayedSportsId.length == 0 ? true : displayedSportsId.includes(sport.id)
displayedSportsId.includes(sport.id)
)
const labels: string[] = []
const datasets = getDatasets(displayedSports)

View File

@ -0,0 +1,41 @@
<template>
<div id="statistics">
<div class="container" v-if="authUser.username">
<Statistics :user="authUser" :sports="sports" />
</div>
</div>
</template>
<script lang="ts">
import { ComputedRef, computed, defineComponent } from 'vue'
import Statistics from '@/components/Statistics/index.vue'
import { USER_STORE, SPORTS_STORE } from '@/store/constants'
import { ISport } from '@/types/sports'
import { IAuthUserProfile } from '@/types/user'
import { useStore } from '@/use/useStore'
export default defineComponent({
name: 'StatisticsView',
components: {
Statistics,
},
setup() {
const store = useStore()
const authUser: ComputedRef<IAuthUserProfile> = computed(
() => store.getters[USER_STORE.GETTERS.AUTH_USER_PROFILE]
)
const sports: ComputedRef<ISport[]> = computed(
() => store.getters[SPORTS_STORE.GETTERS.SPORTS]
)
return { authUser, sports }
},
})
</script>
<style lang="scss" scoped>
@import '~@/scss/base';
#statistics {
height: 100%;
}
</style>

View File

@ -254,57 +254,9 @@ describe('formatStats', () => {
const expected: IStatisticsChartData = {
labels: ['2021-05', '2021-06', '2021-07'],
datasets: {
nb_workouts: [
{
label: 'Cycling (Sport)',
backgroundColor: ['#4c9792'],
data: [0, 0, 0],
},
{
label: 'Cycling (Transport)',
backgroundColor: ['#88af98'],
data: [0, 0, 0],
},
{
label: 'Hiking',
backgroundColor: ['#bb757c'],
data: [0, 0, 0],
},
],
total_distance: [
{
label: 'Cycling (Sport)',
backgroundColor: ['#4c9792'],
data: [0, 0, 0],
},
{
label: 'Cycling (Transport)',
backgroundColor: ['#88af98'],
data: [0, 0, 0],
},
{
label: 'Hiking',
backgroundColor: ['#bb757c'],
data: [0, 0, 0],
},
],
total_duration: [
{
label: 'Cycling (Sport)',
backgroundColor: ['#4c9792'],
data: [0, 0, 0],
},
{
label: 'Cycling (Transport)',
backgroundColor: ['#88af98'],
data: [0, 0, 0],
},
{
label: 'Hiking',
backgroundColor: ['#bb757c'],
data: [0, 0, 0],
},
],
nb_workouts: [],
total_distance: [],
total_duration: [],
},
}
assert.deepEqual(
@ -352,7 +304,7 @@ describe('formatStats', () => {
)
})
it('returns empty datasets if data and no displayed sport provided', () => {
it('returns empty datasets if data provided but no displayed sport', () => {
const inputStats: TStatisticsFromApi = {
'2021-05': {
1: {
@ -389,57 +341,9 @@ describe('formatStats', () => {
const expected: IStatisticsChartData = {
labels: ['2021-05', '2021-06', '2021-07'],
datasets: {
nb_workouts: [
{
label: 'Cycling (Sport)',
backgroundColor: ['#4c9792'],
data: [1, 1, 0],
},
{
label: 'Cycling (Transport)',
backgroundColor: ['#88af98'],
data: [0, 2, 0],
},
{
label: 'Hiking',
backgroundColor: ['#bb757c'],
data: [0, 0, 2],
},
],
total_distance: [
{
label: 'Cycling (Sport)',
backgroundColor: ['#4c9792'],
data: [10, 15, 0],
},
{
label: 'Cycling (Transport)',
backgroundColor: ['#88af98'],
data: [0, 20, 0],
},
{
label: 'Hiking',
backgroundColor: ['#bb757c'],
data: [0, 0, 12],
},
],
total_duration: [
{
label: 'Cycling (Sport)',
backgroundColor: ['#4c9792'],
data: [3000, 3500, 0],
},
{
label: 'Cycling (Transport)',
backgroundColor: ['#88af98'],
data: [0, 3000, 0],
},
{
label: 'Hiking',
backgroundColor: ['#bb757c'],
data: [0, 0, 5000],
},
],
nb_workouts: [],
total_distance: [],
total_duration: [],
},
}
assert.deepEqual(