Client - improve calendar display on desktop and mobile (wip)

This commit is contained in:
Sam 2021-09-30 21:09:15 +02:00
parent d9790e0465
commit ba0b94de45
10 changed files with 500 additions and 62 deletions

View File

@ -138,7 +138,7 @@
.calendar-cell { .calendar-cell {
border-right: solid 1px var(--calendar-border-color); border-right: solid 1px var(--calendar-border-color);
height: 3em; height: 40px;
flex-grow: 1; flex-grow: 1;
flex-basis: 8%; flex-basis: 8%;
padding: $default-padding * 0.5 $default-padding $default-padding * 0.5 padding: $default-padding * 0.5 $default-padding $default-padding * 0.5

View File

@ -50,7 +50,8 @@
@import '~@/scss/base'; @import '~@/scss/base';
.calendar-workout { .calendar-workout {
padding: 2px 0; display: flex;
padding: 1px;
cursor: pointer; cursor: pointer;
img { img {
max-width: 18px; max-width: 18px;
@ -58,8 +59,9 @@
} }
sup { sup {
position: relative; position: relative;
top: -0.6em; top: -8px;
left: -0.4em; left: -3px;
width: 2px;
.custom-fa-small { .custom-fa-small {
font-size: 0.7em; font-size: 0.7em;
} }

View File

@ -1,45 +1,54 @@
<template> <template>
<div class="calendar-workouts"> <div class="calendar-workouts">
<div v-for="(workout, index) in workouts.slice(0, 6)" :key="index"> <div class="desktop-display">
<CalendarWorkout
:workout="workout"
:sportImg="getSportImg(workout, sports)"
/>
</div>
<div v-if="workouts.length > 6">
<i
class="fa calendar-more"
:class="`fa-${isHidden ? 'plus' : 'times'}`"
aria-hidden="true"
title="show more workouts"
@click="displayMore"
/>
<div v-if="!isHidden" class="more-workouts">
<div <div
v-for="(workout, index) in workouts.slice(6 - workouts.length)" class="workouts-display"
:key="index" v-if="workouts.length <= displayedWorkoutCount"
> >
<CalendarWorkout <CalendarWorkout
v-for="(workout, index) in workouts.slice(0, displayedWorkoutCount)"
:key="index"
:workout="workout" :workout="workout"
:sportImg="getSportImg(workout, sports)" :sportImg="getSportImg(workout, sports)"
/> />
</div> </div>
<div v-else class="donut-display">
<CalendarWorkoutsChart
:workouts="workouts"
:sports="sports"
:datasets="chartDatasets"
:colors="colors"
/>
</div>
</div>
<div class="mobile-display">
<div class="donut-display" v-if="workouts.length > 0">
<CalendarWorkoutsChart
:workouts="workouts"
:sports="sports"
:datasets="chartDatasets"
:colors="colors"
/>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { PropType, defineComponent, ref } from 'vue' import { PropType, computed, defineComponent } from 'vue'
import CalendarWorkout from '@/components/Dashboard/UserCalendar/CalendarWorkout.vue' import CalendarWorkout from '@/components/Dashboard/UserCalendar/CalendarWorkout.vue'
import CalendarWorkoutsChart from '@/components/Dashboard/UserCalendar/CalendarWorkoutsChart.vue'
import { ISport } from '@/types/sports' import { ISport } from '@/types/sports'
import { IWorkout } from '@/types/workouts' import { IWorkout } from '@/types/workouts'
import { getSportImg, sportIdColors } from '@/utils/sports'
import { getDonutDatasets } from '@/utils/workouts'
export default defineComponent({ export default defineComponent({
name: 'CalendarWorkouts', name: 'CalendarWorkouts',
components: { components: {
CalendarWorkout, CalendarWorkout,
CalendarWorkoutsChart,
}, },
props: { props: {
workouts: { workouts: {
@ -51,17 +60,13 @@
required: true, required: true,
}, },
}, },
setup() { setup(props) {
const isHidden = ref(true) return {
function getSportImg(workout: IWorkout, sports: ISport[]): string { chartDatasets: computed(() => getDonutDatasets(props.workouts)),
return sports colors: computed(() => sportIdColors(props.sports)),
.filter((sport) => sport.id === workout.sport_id) displayedWorkoutCount: 6,
.map((sport) => sport.img)[0] getSportImg,
} }
function displayMore() {
isHidden.value = !isHidden.value
}
return { isHidden, getSportImg, displayMore }
}, },
}) })
</script> </script>
@ -69,32 +74,32 @@
<style lang="scss"> <style lang="scss">
@import '~@/scss/base'; @import '~@/scss/base';
.calendar-workouts { .calendar-workouts {
.desktop-display {
display: flex;
}
.mobile-display {
display: none;
}
.workouts-display {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
position: relative; position: relative;
margin: 0 $default-padding 0 0;
.calendar-more {
position: absolute;
top: 30px;
right: -3px;
} }
.more-workouts { .donut-display {
background: whitesmoke;
border-radius: 4px;
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2),
0 6px 20px 0 rgba(0, 0, 0, 0.19);
position: absolute;
top: 52px;
left: 0;
min-width: 53px;
margin-bottom: 20px;
padding: 10px 15px;
display: flex; display: flex;
flex-wrap: wrap; height: 34px;
z-index: 1000; width: 34px;
}
@media screen and (max-width: $small-limit) {
.desktop-display {
display: none;
}
.mobile-display {
display: flex;
}
} }
} }
</style> </style>

View File

@ -0,0 +1,134 @@
<template>
<div class="calendar-workouts-chart">
<div class="workouts-chart" @click="togglePane">
<div class="workouts-count">{{ workouts.length }}</div>
<DonutChart :datasets="datasets" :colors="colors" />
</div>
<div class="workouts-pane" v-if="!isHidden">
<div class="more-workouts" v-click-outside="togglePane">
<i
class="fa fa-times calendar-more"
aria-hidden="true"
@click="togglePane"
/>
<div v-for="(workout, index) in workouts" :key="index">
<CalendarWorkout
:workout="workout"
:sportImg="getSportImg(workout, sports)"
/>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { PropType, defineComponent, ref } from 'vue'
import CalendarWorkout from '@/components/Dashboard/UserCalendar/CalendarWorkout.vue'
import DonutChart from '@/components/Dashboard/UserCalendar/DonutChart.vue'
import { ISport } from '@/types/sports'
import { IWorkout } from '@/types/workouts'
import { getSportImg } from '@/utils/sports'
export default defineComponent({
name: 'CalendarWorkoutsChart',
components: {
CalendarWorkout,
DonutChart,
},
props: {
colors: {
type: Object as PropType<Record<number, string>>,
required: true,
},
datasets: {
type: Object as PropType<Record<number, Record<string, number>>>,
required: true,
},
sports: {
type: Object as PropType<ISport[]>,
required: true,
},
workouts: {
type: Object as PropType<IWorkout[]>,
required: true,
},
},
setup() {
const isHidden = ref(true)
function togglePane(event: Event & { target: HTMLElement }) {
event.stopPropagation()
isHidden.value = !isHidden.value
}
return { isHidden, getSportImg, togglePane }
},
})
</script>
<style lang="scss" scoped>
@import '~@/scss/base';
.calendar-workouts-chart {
display: flex;
.workouts-chart {
position: relative;
.workouts-count {
display: flex;
justify-content: center;
position: absolute;
top: 4px;
left: 6px;
width: 20px;
font-size: 1.1em;
font-weight: bold;
}
@media screen and (max-width: $small-limit) {
.workouts-count {
top: 16px;
left: 6px;
}
::v-deep(.donut-chart) {
padding-top: 12px;
svg g circle {
stroke-width: 2;
stroke-opacity: 0.8;
}
}
}
}
.workouts-pane {
display: flex;
padding-left: 40px;
.more-workouts {
background: whitesmoke;
border-radius: 4px;
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2),
0 6px 20px 0 rgba(0, 0, 0, 0.19);
position: absolute;
top: 52px;
left: 0;
min-width: 60px;
@media screen and (max-width: $small-limit) {
min-width: 40px;
}
margin-bottom: 20px;
padding: 10px 10px;
display: flex;
flex-wrap: wrap;
z-index: 1000;
.calendar-more {
position: absolute;
font-size: 0.9em;
top: 5px;
right: 5px;
}
}
}
}
</style>

View File

@ -0,0 +1,73 @@
// adapted from: https://css-tricks.com/building-a-donut-chart-with-vue-and-svg/
<template>
<div class="donut-chart">
<svg height="34" width="34" viewBox="0 0 34 34">
<g v-for="(data, index) of Object.entries(datasets)" :key="index">
<circle
:cx="cx"
:cy="cy"
:r="radius"
fill="transparent"
:stroke="colors[+data[0]]"
:stroke-dashoffset="
calculateStrokeDashOffset(data[1].percentage, circumference)
"
:stroke-dasharray="circumference"
stroke-width="3"
stroke-opacity="0.8"
:transform="returnCircleTransformValue(index, data[1].percentage)"
/>
</g>
</svg>
</div>
</template>
<script lang="ts">
import { PropType, defineComponent } from 'vue'
export default defineComponent({
name: 'DonutChart',
props: {
colors: {
type: Object as PropType<Record<number, string>>,
required: true,
},
datasets: {
type: Object as PropType<Record<number, Record<string, number>>>,
required: true,
},
},
setup() {
let angleOffset = -90
const cx = 16
const cy = 16
const radius = 14
const circumference = 2 * Math.PI * radius
function calculateStrokeDashOffset(
percentage: number,
circumference: number
): number {
return circumference - percentage * circumference
}
function returnCircleTransformValue(
index: number,
percentage: number
): string {
const rotation = `rotate(${angleOffset}, ${cx}, ${cy})`
angleOffset = percentage * 360 + angleOffset
return rotation
}
return {
angleOffset,
circumference,
cx,
cy,
radius,
calculateStrokeDashOffset,
returnCircleTransformValue,
}
},
})
</script>

View File

@ -0,0 +1,27 @@
import { Directive, DirectiveBinding } from 'vue'
interface ClickOutsideHTMLElement extends HTMLElement {
clickOutsideEvent?: (event: MouseEvent | TouchEvent) => void
}
export const clickOutsideDirective: Directive = {
mounted: (
element: ClickOutsideHTMLElement,
binding: DirectiveBinding
): void => {
element.clickOutsideEvent = function (event) {
if (!(element === event.target || element.contains(<Node>event.target))) {
binding.value(event)
}
}
document.body.addEventListener('click', element.clickOutsideEvent)
document.body.addEventListener('touchstart', element.clickOutsideEvent)
},
unmounted: function (element: ClickOutsideHTMLElement): void {
if (element.clickOutsideEvent) {
document.body.removeEventListener('click', element.clickOutsideEvent)
document.body.removeEventListener('touchstart', element.clickOutsideEvent)
element.clickOutsideEvent = undefined
}
},
}

View File

@ -6,4 +6,11 @@ import i18n from './i18n'
import router from './router' import router from './router'
import store from './store' import store from './store'
createApp(App).use(i18n).use(store).use(router).mount('#app') import { clickOutsideDirective } from '@/directives'
createApp(App)
.use(i18n)
.use(store)
.use(router)
.directive('click-outside', clickOutsideDirective)
.mount('#app')

View File

@ -1,4 +1,5 @@
import { ISport } from '@/types/sports' import { ISport } from '@/types/sports'
import { IWorkout } from '@/types/workouts'
export const sportColors: Record<string, string> = { export const sportColors: Record<string, string> = {
'Cycling (Sport)': '#55A8A3', 'Cycling (Sport)': '#55A8A3',
@ -9,6 +10,12 @@ export const sportColors: Record<string, string> = {
Walking: '#929292', Walking: '#929292',
} }
export const sportIdColors = (sports: ISport[]): Record<number, string> => {
const colors: Record<number, string> = {}
sports.map((sport) => (colors[sport.id] = sportColors[sport.label]))
return colors
}
const sortSports = (a: ISport, b: ISport): number => { const sortSports = (a: ISport, b: ISport): number => {
const sportALabel = a.label.toLowerCase() const sportALabel = a.label.toLowerCase()
const sportBLabel = b.label.toLowerCase() const sportBLabel = b.label.toLowerCase()
@ -27,3 +34,9 @@ export const translateSports = (
label: t(`sports.${sport.label}.LABEL`), label: t(`sports.${sport.label}.LABEL`),
})) }))
.sort(sortSports) .sort(sortSports)
export const getSportImg = (workout: IWorkout, sports: ISport[]): string => {
return sports
.filter((sport) => sport.id === workout.sport_id)
.map((sport) => sport.img)[0]
}

View File

@ -1,4 +1,5 @@
import { import {
IWorkout,
IWorkoutApiChartData, IWorkoutApiChartData,
IWorkoutChartData, IWorkoutChartData,
TCoordinates, TCoordinates,
@ -42,3 +43,27 @@ export const getDatasets = (
return { distance_labels, duration_labels, datasets, coordinates } return { distance_labels, duration_labels, datasets, coordinates }
} }
export const getDonutDatasets = (
workouts: IWorkout[]
): Record<number, Record<string, number>> => {
const total = workouts.length
if (total === 0) {
return {}
}
const datasets: Record<number, Record<string, number>> = {}
workouts.map((workout) => {
if (!datasets[workout.sport_id]) {
datasets[workout.sport_id] = {
count: 0,
percentage: 0,
}
}
datasets[workout.sport_id].count += 1
datasets[workout.sport_id].percentage =
datasets[workout.sport_id].count / total
})
return datasets
}

View File

@ -1,7 +1,7 @@
import { assert } from 'chai' import { assert } from 'chai'
import createI18n from '@/i18n' import createI18n from '@/i18n'
import { getDatasets } from '@/utils/workouts' import { getDatasets, getDonutDatasets } from '@/utils/workouts'
const { t, locale } = createI18n.global const { t, locale } = createI18n.global
@ -113,3 +113,155 @@ describe('getDatasets', () => {
}) })
}) })
}) })
describe('getDonutDatasets', () => {
const testparams = [
{
description: 'returns empty datasets when no workouts provided',
input: [],
expected: {},
},
{
description: 'returns donut chart datasets w/ count and percentage',
input: [
{
ascent: null,
ave_speed: 10.0,
bounds: [],
creation_date: 'Sun, 14 Jul 2019 13:51:01 GMT',
descent: null,
distance: 10.0,
duration: '0:17:04',
id: 'TfJ9nHVvoyxF2B8YBmMDB8',
map: null,
max_alt: null,
max_speed: 10.0,
min_alt: null,
modification_date: null,
moving: '0:17:04',
next_workout: 'kjxavSTUrJvoAh2wvCeGEF',
notes: '',
pauses: null,
previous_workout: null,
records: [],
segments: [],
sport_id: 2,
title: 'Cycling (Transport)',
user: 'admin',
weather_end: null,
weather_start: null,
with_gpx: false,
workout_date: 'Mon, 01 Jan 2018 00:00:00 GMT',
},
{
ascent: null,
ave_speed: 16,
bounds: [],
creation_date: 'Sun, 14 Jul 2019 18:57:14 GMT',
descent: null,
distance: 12,
duration: '0:45:00',
id: 'kjxavSTUrJvoAh2wvCeGEF',
map: null,
max_alt: null,
max_speed: 16,
min_alt: null,
modification_date: 'Sun, 14 Jul 2019 18:57:22 GMT',
moving: '0:45:00',
next_workout: 'TfJ9nHVvoyxF2B8YBmMDB8',
notes: 'workout without gpx',
pauses: null,
previous_workout: 'TfJ9nHVvoyxF2B8YBmMDB8',
records: [],
segments: [],
sport_id: 1,
title: 'biking on sunday morning',
user: 'admin',
weather_end: null,
weather_start: null,
with_gpx: false,
workout_date: 'Sun, 07 Jul 2019 07:00:00 GMT',
},
{
ascent: null,
ave_speed: 5.31,
bounds: [],
creation_date: 'Wed, 29 Sep 2021 06:18:44 GMT',
descent: null,
distance: 6.3,
duration: '1:11:10',
id: 'eYwTr2A5L6xX52rwwrfL4A',
map: null,
max_alt: null,
max_speed: 5.31,
min_alt: null,
modification_date: 'Wed, 29 Sep 2021 06:54:02 GMT',
moving: '1:11:10',
next_workout: 'oN4kVTRCdsy2cGNKANSJKM',
notes: '',
pauses: null,
previous_workout: 'kjxavSTUrJvoAh2wvCeGEF',
records: [],
segments: [],
sport_id: 2,
title: 'Cycling (Transport) - 2021-09-21 21:00:00',
user: 'admin',
weather_end: null,
weather_start: null,
with_gpx: false,
workout_date: 'Tue, 21 Sep 2021 19:00:00 GMT',
},
{
ascent: null,
ave_speed: 3.97,
bounds: [],
creation_date: 'Thu, 30 Sep 2021 18:55:54 GMT',
descent: null,
distance: 5,
duration: '1:15:30',
id: 'FiRvMtGJCp56dqN8qfn8BK',
map: null,
max_alt: null,
max_speed: 3.97,
min_alt: null,
modification_date: null,
moving: '1:15:30',
next_workout: '2GZm7YgULHi9b4kCHDbHsY',
notes: '',
pauses: null,
previous_workout: '2GZm7YgULHi9b4kCHDbHsY',
records: [],
segments: [],
sport_id: 3,
title: 'just hiking',
user: 'admin',
weather_end: null,
weather_start: null,
with_gpx: false,
workout_date: 'Mon, 20 Sep 2021 07:00:00 GMT',
},
],
expected: {
1: {
count: 1,
percentage: 0.25,
},
2: {
count: 2,
percentage: 0.5,
},
3: {
count: 1,
percentage: 0.25,
},
},
},
]
testparams.map((testParams) => {
it(testParams.description, () => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
assert.deepEqual(getDonutDatasets(testParams.input), testParams.expected)
})
})
})