Client - add workouts list view

This commit is contained in:
Sam 2021-10-05 15:23:41 +02:00
parent e75e3487e6
commit b1acb6c570
17 changed files with 821 additions and 6 deletions

View File

@ -1,6 +1,12 @@
<template>
<div class="static-map">
<div class="static-map" :class="{ 'display-hover': displayHover }">
<img
v-if="displayHover"
:src="`${getApiUrl()}workouts/map/${workout.map}`"
alt=""
/>
<div
v-else
class="bg-map-image"
:style="{
backgroundImage: `url(${getApiUrl()}workouts/map/${workout.map})`,
@ -33,6 +39,10 @@
type: Object as PropType<IWorkout>,
required: true,
},
displayHover: {
type: Boolean,
default: false,
},
},
setup() {
return { getApiUrl }
@ -47,6 +57,14 @@
display: flex;
position: relative;
&.display-hover {
position: absolute;
margin-left: 20px;
margin-top: 10px;
width: 400px;
z-index: 100;
}
.bg-map-image {
background-size: cover;
background-position: center;

View File

@ -92,7 +92,10 @@
}
function loadMoreWorkouts() {
page.value += 1
loadWorkouts()
store.dispatch(WORKOUTS_STORE.ACTIONS.GET_MORE_USER_WORKOUTS, {
page: page.value,
per_page,
})
}
return {

View File

@ -23,9 +23,9 @@
<router-link class="nav-item" to="/">{{
t('dashboard.DASHBOARD')
}}</router-link>
<div class="nav-item">
<router-link class="nav-item" to="/workouts">
{{ capitalize(t('workouts.WORKOUT', 2)) }}
</div>
</router-link>
<router-link class="nav-item" to="/statistics">
{{ t('statistics.STATISTICS') }}
</router-link>

View File

@ -0,0 +1,308 @@
<template>
<div class="workouts-filters">
<Card :without-title="true">
<template #content>
<div class="form">
<div class="form-items-group">
<div class="form-item">
<label> {{ t('workouts.FROM') }}: </label>
<input name="from" type="date" @change="handleFilterChange" />
</div>
<div class="form-item">
<label> {{ t('workouts.TO') }}: </label>
<input name="to" type="date" @change="handleFilterChange" />
</div>
</div>
<div class="form-items-group">
<div class="form-item">
<label> {{ t('workouts.SPORT', 1) }}:</label>
<select name="sport_id" @change="handleFilterChange">
<option value="" />
<option
v-for="sport in translatedSports"
:value="sport.id"
:key="sport.id"
>
{{ sport.label }}
</option>
</select>
</div>
</div>
<div class="form-items-group">
<div class="form-item">
<label> {{ t('workouts.DISTANCE') }} (km): </label>
<div class="form-inputs-group">
<input
name="distance_from"
type="number"
min="0"
step="1"
@change="handleFilterChange"
/>
<span>{{ t('workouts.TO') }}</span>
<input
name="distance_to"
type="number"
min="0"
step="1"
@change="handleFilterChange"
/>
</div>
</div>
</div>
<div class="form-items-group">
<div class="form-item">
<label> {{ t('workouts.DURATION') }} (km): </label>
<div class="form-inputs-group">
<input
name="duration_from"
@change="handleFilterChange"
pattern="^([0-9]*[0-9]):([0-5][0-9])$"
placeholder="hh:mm"
type="text"
/>
<span>{{ t('workouts.TO') }}</span>
<input
name="duration_to"
@change="handleFilterChange"
pattern="^([0-9]*[0-9]):([0-5][0-9])$"
placeholder="hh:mm"
type="text"
/>
</div>
</div>
</div>
<div class="form-items-group">
<div class="form-item">
<label> {{ t('workouts.AVE_SPEED') }} (km): </label>
<div class="form-inputs-group">
<input
min="0"
name="ave_speed_from"
@change="handleFilterChange"
step="1"
type="number"
/>
<span>{{ t('workouts.TO') }}</span>
<input
min="0"
name="ave_speed_to"
@change="handleFilterChange"
step="1"
type="number"
/>
</div>
</div>
</div>
<div class="form-items-group">
<div class="form-item">
<label> {{ t('workouts.MAX_SPEED') }} (km): </label>
<div class="form-inputs-group">
<input
min="0"
name="max_speed_from"
@change="handleFilterChange"
step="1"
type="number"
/>
<span>{{ t('workouts.TO') }}</span>
<input
min="0"
name="max_speed_to"
@change="handleFilterChange"
step="1"
type="number"
/>
</div>
</div>
</div>
</div>
<div class="form-button">
<button class="confirm" @click="onFilter">
{{ t('buttons.FILTER') }}
</button>
</div>
</template>
</Card>
</div>
</template>
<script lang="ts">
import { computed, ComputedRef, defineComponent, PropType } from 'vue'
import { useI18n } from 'vue-i18n'
import Card from '@/components/Common/Card.vue'
import { ISport } from '@/types/sports'
import { translateSports } from '@/utils/sports'
export default defineComponent({
name: 'WorkoutsFilters',
components: {
Card,
},
props: {
sports: {
type: Object as PropType<ISport[]>,
required: true,
},
},
emits: ['filter', 'filtersUpdate'],
setup(props, { emit }) {
const { t } = useI18n()
const translatedSports: ComputedRef<ISport[]> = computed(() =>
translateSports(props.sports, t)
)
const params: Record<string, string> = {}
function handleFilterChange(event: Event & { target: HTMLInputElement }) {
if (event.target.value === '') {
delete params[event.target.name]
} else {
params[event.target.name] = event.target.value
}
}
function onFilter() {
emit('filter', { ...params })
}
return { t, translatedSports, onFilter, handleFilterChange }
},
})
</script>
<style lang="scss" scoped>
@import '~@/scss/base.scss';
.workouts-filters {
::v-deep(.card) {
.card-content {
padding: $default-padding;
.form {
display: flex;
flex-direction: column;
padding-top: 0;
.form-items-group {
display: flex;
flex-direction: column;
padding: $default-padding * 0.5;
.form-item {
display: flex;
flex-direction: column;
.form-inputs-group {
display: flex;
flex-direction: row;
justify-content: space-around;
align-items: center;
input {
width: 34%;
}
span {
padding: $default-padding * 0.5;
}
}
input {
height: 16px;
}
select {
height: 36px;
padding: 0 $default-padding * 0.5;
}
}
}
}
.form-button {
display: flex;
justify-content: center;
button {
margin: $default-padding * 2 $default-padding * 0.5 $default-padding
$default-padding * 0.5;
width: 100%;
}
}
@media screen and (max-width: $medium-limit) {
.form {
flex-direction: row;
padding-top: $default-padding * 0.5;
.form-items-group {
padding: 0 $default-padding * 0.5;
height: 100%;
.form-item {
label {
font-size: 0.9em;
}
.form-inputs-group {
flex-direction: column;
justify-content: normal;
padding: 0;
input {
width: 75%;
}
}
}
}
}
.form-button {
button {
margin: $default-padding $default-padding * 0.5;
width: 100%;
}
}
}
@media screen and (max-width: $small-limit) {
.form {
flex-direction: column;
padding-top: 0;
.form-items-group {
padding: $default-padding * 0.5;
.form-item {
label {
font-size: 1em;
}
.form-inputs-group {
flex-direction: row;
justify-content: space-around;
align-items: center;
input {
width: 50%;
}
span {
padding: $default-padding * 0.5;
}
}
}
}
}
.form-button {
button {
margin: $default-padding $default-padding * 0.5;
}
}
}
}
}
}
</style>

View File

@ -0,0 +1,341 @@
<template>
<div class="workouts-list">
<Card :without-title="true">
<template #content>
<div class="workouts-table">
<table>
<thead>
<tr>
<th class="sport-col" />
<th>{{ capitalize(t('workouts.WORKOUT', 1)) }}</th>
<th>{{ capitalize(t('workouts.DATE')) }}</th>
<th>{{ capitalize(t('workouts.DISTANCE')) }}</th>
<th>{{ capitalize(t('workouts.DURATION')) }}</th>
<th>{{ capitalize(t('workouts.AVE_SPEED')) }}</th>
<th>{{ capitalize(t('workouts.MAX_SPEED')) }}</th>
</tr>
</thead>
<tbody>
<tr v-for="workout in workouts" :key="workout.id">
<td class="sport-col">
<span class="cell-heading">
{{ t('workouts.SPORT', 1) }}
</span>
<SportImage
:title="
sports.filter((s) => s.id === workout.sport_id)[0]
.translatedLabel
"
:sport-label="
sports.filter((s) => s.id === workout.sport_id)[0].label
"
></SportImage>
</td>
<td class="workout-title">
<span class="cell-heading">
{{ capitalize(t('workouts.WORKOUT', 1)) }}
</span>
<router-link
class="nav-item"
:to="{ name: 'Workout', params: { workoutId: workout.id } }"
>
<i
v-if="workout.with_gpx"
class="fa fa-map-o"
aria-hidden="true"
/>
{{ workout.title }}
</router-link>
<StaticMap
v-if="workout.with_gpx"
:workout="workout"
:display-hover="true"
/>
</td>
<td>
<span class="cell-heading">
{{ t('workouts.DATE') }}
</span>
{{
format(
getDateWithTZ(workout.workout_date, user.timezone),
'dd/MM/yyyy HH:mm'
)
}}
</td>
<td class="text-right">
<span class="cell-heading">
{{ t('workouts.DISTANCE') }}
</span>
{{ Number(workout.distance).toFixed(2) }} km
</td>
<td class="text-right">
<span class="cell-heading">
{{ t('workouts.DURATION') }}
</span>
{{ workout.moving }}
</td>
<td class="text-right">
<span class="cell-heading">
{{ t('workouts.AVE_SPEED') }}
</span>
{{ workout.ave_speed }} km/h
</td>
<td class="text-right">
<span class="cell-heading">
{{ t('workouts.MAX_SPEED') }}
</span>
{{ workout.max_speed }} km/h
</td>
</tr>
</tbody>
</table>
</div>
</template>
</Card>
<div v-if="moreWorkoutsExist" class="more-workouts">
<button @click="loadMoreWorkouts">
{{ t('workouts.LOAD_MORE_WORKOUT') }}
</button>
</div>
<div id="bottom" />
</div>
</template>
<script lang="ts">
import { format } from 'date-fns'
import {
ComputedRef,
PropType,
computed,
defineComponent,
ref,
watch,
onBeforeMount,
} from 'vue'
import { useI18n } from 'vue-i18n'
import Card from '@/components/Common/Card.vue'
import SportImage from '@/components/Common/SportImage/index.vue'
import StaticMap from '@/components/Common/StaticMap.vue'
import { WORKOUTS_STORE } from '@/store/constants'
import { ITranslatedSport } from '@/types/sports'
import { IAuthUserProfile } from '@/types/user'
import { IWorkout } from '@/types/workouts'
import { useStore } from '@/use/useStore'
import { capitalize } from '@/utils'
import { getDateWithTZ } from '@/utils/dates'
export default defineComponent({
name: 'WorkoutsList',
components: {
Card,
SportImage,
StaticMap,
},
props: {
params: {
type: Object as PropType<Record<string, string>>,
required: true,
},
user: {
type: Object as PropType<IAuthUserProfile>,
required: true,
},
sports: {
type: Object as PropType<ITranslatedSport[]>,
},
},
setup(props) {
const store = useStore()
const { t } = useI18n()
const workouts: ComputedRef<IWorkout[]> = computed(
() => store.getters[WORKOUTS_STORE.GETTERS.USER_WORKOUTS]
)
const per_page = 10
const page = ref(1)
const moreWorkoutsExist: ComputedRef<boolean> = computed(() =>
workouts.value.length > 0
? workouts.value[workouts.value.length - 1].previous_workout !== null
: false
)
onBeforeMount(() => {
loadWorkouts()
})
function loadWorkouts() {
page.value = 1
store.dispatch(WORKOUTS_STORE.ACTIONS.GET_USER_WORKOUTS, {
page: page.value,
per_page,
...props.params,
})
}
function loadMoreWorkouts() {
page.value += 1
store.dispatch(WORKOUTS_STORE.ACTIONS.GET_MORE_USER_WORKOUTS, {
page: page.value,
per_page,
...props.params,
})
}
watch(
() => props.params,
async () => {
loadWorkouts()
}
)
return {
moreWorkoutsExist,
t,
workouts,
capitalize,
format,
getDateWithTZ,
loadMoreWorkouts,
}
},
})
</script>
<style lang="scss" scoped>
@import '~@/scss/base.scss';
.workouts-list {
display: flex;
flex-direction: column;
margin-bottom: 50px;
width: 100%;
::v-deep(.card) {
.card-content {
.workouts-table {
/* responsive table, adapted from: */
/* https://uglyduck.ca/making-tables-responsive-with-minimal-css/ */
table {
width: 100%;
padding: $default-padding;
font-size: 0.9em;
border-collapse: collapse;
thead th {
vertical-align: center;
padding: $default-padding;
border-bottom: 2px solid var(--card-border-color);
}
tbody {
font-size: 0.95em;
td {
padding: $default-padding;
border-bottom: 1px solid var(--card-border-color);
}
tr:last-child td {
border: none;
}
}
.sport-col {
padding-right: 0;
}
.workout-title {
max-width: 90px;
position: relative;
.fa-map-o {
font-size: 0.75em;
}
.static-map {
display: none;
}
}
.workout-title:hover .static-map {
display: block;
}
.cell-heading {
background: var(--cell-heading-bg-color);
color: var(--cell-heading-color);
display: none;
font-size: 10px;
font-weight: bold;
padding: 5px;
position: absolute;
text-transform: uppercase;
top: 0;
left: 0;
}
.sport-img {
height: 20px;
width: 20px;
}
}
@media screen and (max-width: $small-limit) {
table {
thead {
left: -9999px;
position: absolute;
visibility: hidden;
}
tr {
border-bottom: 0;
display: flex;
flex-direction: row;
flex-wrap: wrap;
margin-bottom: 40px;
}
td {
border: 1px solid var(--card-border-color);
margin: 0 -1px -1px 0;
padding-top: 25px !important;
position: relative;
text-align: center;
width: 45%;
}
tbody {
tr:last-child td {
border: 1px solid var(--card-border-color);
}
}
.sport-col {
display: flex;
justify-content: center;
padding: $default-padding;
}
.cell-heading {
display: flex;
}
.workout-title {
max-width: initial;
}
}
}
@media screen and (max-width: $x-small-limit) {
table {
td {
width: 100%;
}
}
}
}
}
}
.more-workouts {
display: flex;
justify-content: center;
padding: $default-padding;
}
}
</style>

View File

@ -1,5 +1,6 @@
{
"CANCEL": "Cancel",
"FILTER": "Filter",
"LOGIN": "Log in",
"NO": "No",
"REGISTER": "Register",

View File

@ -2,14 +2,17 @@
"ADD_WORKOUT": "Add a workout",
"ANALYSIS": "analysis",
"ASCENT": "ascent",
"AVE_SPEED": "ave. speed",
"AVERAGE_SPEED": "average speed",
"BACK_TO_WORKOUT": "back to workout",
"DATE": "date",
"DESCENT": "descent",
"DISTANCE": "distance",
"DURATION": "duration",
"EDIT_WORKOUT": "Edit the workout",
"ELEVATION": "elevation",
"END": "end",
"FROM": "from",
"GPX_FILE": ".gpx file",
"KM": "km",
"LATEST_WORKOUTS": "Latest workouts",
@ -46,6 +49,7 @@
"SPORT": "sport | sports",
"START": "start",
"TITLE": "title",
"TO": "to",
"TOTAL_DURATION": "total duration",
"WEATHER": {
"HUMIDITY": "humidity",

View File

@ -1,5 +1,6 @@
{
"CANCEL": "Annuler",
"FILTER": "Filtrer",
"LOGIN": "Se connecter",
"NO": "Non",
"REGISTER": "S'inscrire",

View File

@ -3,13 +3,16 @@
"ANALYSIS": "analyse",
"ASCENT": "dénivelé positif",
"AVERAGE_SPEED": "vitesse moyenne",
"AVE_SPEED": "vitesse moy.",
"BACK_TO_WORKOUT": "revenir à la séance",
"DATE": "date",
"DESCENT": "dénivelé négatif",
"DISTANCE": "distance",
"DURATION": "durée",
"EDIT_WORKOUT": "Modifier la séance",
"ELEVATION": "altitude",
"END": "fin",
"FROM": "à partir de",
"GPX_FILE": "fichier .gpx",
"KM": "km",
"LATEST_WORKOUTS": "Séances récentes",
@ -46,6 +49,7 @@
"SPORT": "sport | sports",
"START": "début",
"TITLE": "titre",
"TO": "jusqu'au",
"TOTAL_DURATION": "durée totale",
"WEATHER": {
"HUMIDITY": "humidité",

View File

@ -9,6 +9,7 @@ import LoginOrRegister from '@/views/LoginOrRegister.vue'
import NotFoundView from '@/views/NotFoundView.vue'
import StatisticsView from '@/views/StatisticsView.vue'
import Workout from '@/views/Workout.vue'
import Workouts from '@/views/WorkoutsView.vue'
const routes: Array<RouteRecordRaw> = [
{
@ -33,6 +34,11 @@ const routes: Array<RouteRecordRaw> = [
name: 'Statistics',
component: StatisticsView,
},
{
path: '/workouts',
name: 'Workouts',
component: Workouts,
},
{
path: '/workouts/:workoutId',
name: 'Workout',

View File

@ -54,4 +54,8 @@
brightness(97%) contrast(96%);
--workout-no-map-bg-color: #eaeaea;
--workout-no-map-color: #666666;
--cell-heading-bg-color: #eeeeee;
--cell-heading-color: #696969;
}

View File

@ -19,7 +19,8 @@ import { handleError } from '@/utils'
const getWorkouts = (
context: ActionContext<IWorkoutsState, IRootState>,
payload: IWorkoutsPayload,
target: string
target: string,
append = false
): void => {
context.commit(ROOT_STORE.MUTATIONS.EMPTY_ERROR_MESSAGES)
authApi
@ -31,6 +32,8 @@ const getWorkouts = (
context.commit(
target === 'CALENDAR_WORKOUTS'
? WORKOUTS_STORE.MUTATIONS.SET_CALENDAR_WORKOUTS
: append
? WORKOUTS_STORE.MUTATIONS.ADD_USER_WORKOUTS
: WORKOUTS_STORE.MUTATIONS.SET_USER_WORKOUTS,
res.data.data.workouts
)
@ -56,6 +59,12 @@ export const actions: ActionTree<IWorkoutsState, IRootState> &
): void {
getWorkouts(context, payload, 'USER_WORKOUTS')
},
[WORKOUTS_STORE.ACTIONS.GET_MORE_USER_WORKOUTS](
context: ActionContext<IWorkoutsState, IRootState>,
payload: IWorkoutsPayload
): void {
getWorkouts(context, payload, 'USER_WORKOUTS', true)
},
[WORKOUTS_STORE.ACTIONS.GET_WORKOUT_DATA](
context: ActionContext<IWorkoutsState, IRootState>,
payload: IWorkoutPayload

View File

@ -5,6 +5,7 @@ export enum WorkoutsActions {
EDIT_WORKOUT = 'EDIT_WORKOUT',
GET_CALENDAR_WORKOUTS = 'GET_CALENDAR_WORKOUTS',
GET_USER_WORKOUTS = 'GET_USER_WORKOUTS',
GET_MORE_USER_WORKOUTS = 'GET_MORE_USER_WORKOUTS',
GET_WORKOUT_DATA = 'GET_WORKOUT_DATA',
}
@ -15,6 +16,7 @@ export enum WorkoutsGetters {
}
export enum WorkoutsMutations {
ADD_USER_WORKOUTS = 'ADD_USER_WORKOUTS',
EMPTY_WORKOUTS = 'EMPTY_WORKOUTS',
EMPTY_CALENDAR_WORKOUTS = 'EMPTY_CALENDAR_WORKOUTS',
EMPTY_WORKOUT = 'EMPTY_WORKOUT',

View File

@ -8,6 +8,12 @@ import {
import { IWorkout, IWorkoutApiChartData } from '@/types/workouts'
export const mutations: MutationTree<IWorkoutsState> & TWorkoutsMutations = {
[WORKOUTS_STORE.MUTATIONS.ADD_USER_WORKOUTS](
state: IWorkoutsState,
workouts: IWorkout[]
) {
state.user_workouts = state.user_workouts.concat(workouts)
},
[WORKOUTS_STORE.MUTATIONS.SET_CALENDAR_WORKOUTS](
state: IWorkoutsState,
workouts: IWorkout[]
@ -18,7 +24,7 @@ export const mutations: MutationTree<IWorkoutsState> & TWorkoutsMutations = {
state: IWorkoutsState,
workouts: IWorkout[]
) {
state.user_workouts = state.user_workouts.concat(workouts)
state.user_workouts = workouts
},
[WORKOUTS_STORE.MUTATIONS.SET_WORKOUT](
state: IWorkoutsState,

View File

@ -31,6 +31,10 @@ export interface IWorkoutsActions {
context: ActionContext<IWorkoutsState, IRootState>,
payload: IWorkoutsPayload
): void
[WORKOUTS_STORE.ACTIONS.GET_MORE_USER_WORKOUTS](
context: ActionContext<IWorkoutsState, IRootState>,
payload: IWorkoutsPayload
): void
[WORKOUTS_STORE.ACTIONS.GET_WORKOUT_DATA](
context: ActionContext<IWorkoutsState, IRootState>,
payload: IWorkoutPayload
@ -60,6 +64,10 @@ export interface IWorkoutsGetters {
}
export type TWorkoutsMutations<S = IWorkoutsState> = {
[WORKOUTS_STORE.MUTATIONS.ADD_USER_WORKOUTS](
state: S,
workouts: IWorkout[]
): void
[WORKOUTS_STORE.MUTATIONS.SET_CALENDAR_WORKOUTS](
state: S,
workouts: IWorkout[]

View File

@ -119,6 +119,15 @@ export interface IWorkoutsPayload {
order?: string
per_page?: number
page?: number
ave_speed_from?: string
ave_speed_to?: string
max_speed_from?: string
max_speed_to?: string
distance_from?: string
distance_to?: string
duration_from?: string
duration_to?: string
sport_id?: string
}
export interface IWorkoutApiChartData {

View File

@ -0,0 +1,91 @@
<template>
<div id="workouts" v-if="authUser.username">
<div class="container workouts-container">
<div class="filters-container">
<WorkoutsFilters :sports="translatedSports" @filter="updateParams" />
</div>
<div class="list-container">
<WorkoutsList
:user="authUser"
:params="params"
:sports="translatedSports"
/>
</div>
</div>
</div>
</template>
<script lang="ts">
import { ComputedRef, Ref, computed, defineComponent, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import WorkoutsFilters from '@/components/Workouts/WorkoutsFilters.vue'
import WorkoutsList from '@/components/Workouts/WorkoutsList.vue'
import { USER_STORE, SPORTS_STORE } from '@/store/constants'
import { ISport, ITranslatedSport } from '@/types/sports'
import { IAuthUserProfile } from '@/types/user'
import { useStore } from '@/use/useStore'
import { translateSports } from '@/utils/sports'
export default defineComponent({
name: 'WorkoutsView',
components: {
WorkoutsFilters,
WorkoutsList,
},
setup() {
const { t } = useI18n()
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]
)
const translatedSports: ComputedRef<ITranslatedSport[]> = computed(() =>
translateSports(sports.value, t)
)
const params: Ref<Record<string, string>> = ref({})
function updateParams(filters: Record<string, string>) {
params.value = filters
}
return { authUser, params, translatedSports, updateParams }
},
})
</script>
<style lang="scss" scoped>
@import '~@/scss/base';
#workouts {
height: 100%;
.workouts-container {
display: flex;
flex-direction: row;
@media screen and (max-width: $medium-limit) {
flex-direction: column;
}
.filters-container,
.list-container {
display: flex;
flex-direction: column;
}
.filters-container {
width: 25%;
@media screen and (max-width: $medium-limit) {
width: 100%;
}
}
.list-container {
width: 75%;
@media screen and (max-width: $medium-limit) {
width: 100%;
}
}
}
}
</style>