Client - improve calendar display on desktop and mobile (wip)
This commit is contained in:
parent
d9790e0465
commit
ba0b94de45
@ -138,7 +138,7 @@
|
||||
|
||||
.calendar-cell {
|
||||
border-right: solid 1px var(--calendar-border-color);
|
||||
height: 3em;
|
||||
height: 40px;
|
||||
flex-grow: 1;
|
||||
flex-basis: 8%;
|
||||
padding: $default-padding * 0.5 $default-padding $default-padding * 0.5
|
||||
|
@ -50,7 +50,8 @@
|
||||
@import '~@/scss/base';
|
||||
|
||||
.calendar-workout {
|
||||
padding: 2px 0;
|
||||
display: flex;
|
||||
padding: 1px;
|
||||
cursor: pointer;
|
||||
img {
|
||||
max-width: 18px;
|
||||
@ -58,8 +59,9 @@
|
||||
}
|
||||
sup {
|
||||
position: relative;
|
||||
top: -0.6em;
|
||||
left: -0.4em;
|
||||
top: -8px;
|
||||
left: -3px;
|
||||
width: 2px;
|
||||
.custom-fa-small {
|
||||
font-size: 0.7em;
|
||||
}
|
||||
|
@ -1,45 +1,54 @@
|
||||
<template>
|
||||
<div class="calendar-workouts">
|
||||
<div v-for="(workout, index) in workouts.slice(0, 6)" :key="index">
|
||||
<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 class="desktop-display">
|
||||
<div
|
||||
v-for="(workout, index) in workouts.slice(6 - workouts.length)"
|
||||
:key="index"
|
||||
class="workouts-display"
|
||||
v-if="workouts.length <= displayedWorkoutCount"
|
||||
>
|
||||
<CalendarWorkout
|
||||
v-for="(workout, index) in workouts.slice(0, displayedWorkoutCount)"
|
||||
:key="index"
|
||||
:workout="workout"
|
||||
:sportImg="getSportImg(workout, sports)"
|
||||
/>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { PropType, defineComponent, ref } from 'vue'
|
||||
import { PropType, computed, defineComponent } from 'vue'
|
||||
|
||||
import CalendarWorkout from '@/components/Dashboard/UserCalendar/CalendarWorkout.vue'
|
||||
import CalendarWorkoutsChart from '@/components/Dashboard/UserCalendar/CalendarWorkoutsChart.vue'
|
||||
import { ISport } from '@/types/sports'
|
||||
import { IWorkout } from '@/types/workouts'
|
||||
import { getSportImg, sportIdColors } from '@/utils/sports'
|
||||
import { getDonutDatasets } from '@/utils/workouts'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'CalendarWorkouts',
|
||||
components: {
|
||||
CalendarWorkout,
|
||||
CalendarWorkoutsChart,
|
||||
},
|
||||
props: {
|
||||
workouts: {
|
||||
@ -51,17 +60,13 @@
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const isHidden = ref(true)
|
||||
function getSportImg(workout: IWorkout, sports: ISport[]): string {
|
||||
return sports
|
||||
.filter((sport) => sport.id === workout.sport_id)
|
||||
.map((sport) => sport.img)[0]
|
||||
setup(props) {
|
||||
return {
|
||||
chartDatasets: computed(() => getDonutDatasets(props.workouts)),
|
||||
colors: computed(() => sportIdColors(props.sports)),
|
||||
displayedWorkoutCount: 6,
|
||||
getSportImg,
|
||||
}
|
||||
function displayMore() {
|
||||
isHidden.value = !isHidden.value
|
||||
}
|
||||
return { isHidden, getSportImg, displayMore }
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@ -69,32 +74,32 @@
|
||||
<style lang="scss">
|
||||
@import '~@/scss/base';
|
||||
.calendar-workouts {
|
||||
.desktop-display {
|
||||
display: flex;
|
||||
}
|
||||
.mobile-display {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.workouts-display {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
position: relative;
|
||||
|
||||
.calendar-more {
|
||||
position: absolute;
|
||||
top: 30px;
|
||||
right: -3px;
|
||||
margin: 0 $default-padding 0 0;
|
||||
}
|
||||
.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: 53px;
|
||||
|
||||
margin-bottom: 20px;
|
||||
padding: 10px 15px;
|
||||
|
||||
.donut-display {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
z-index: 1000;
|
||||
height: 34px;
|
||||
width: 34px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: $small-limit) {
|
||||
.desktop-display {
|
||||
display: none;
|
||||
}
|
||||
.mobile-display {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@ -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>
|
@ -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>
|
27
fittrackee_client/src/directives.ts
Normal file
27
fittrackee_client/src/directives.ts
Normal 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
|
||||
}
|
||||
},
|
||||
}
|
@ -6,4 +6,11 @@ import i18n from './i18n'
|
||||
import router from './router'
|
||||
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')
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { ISport } from '@/types/sports'
|
||||
import { IWorkout } from '@/types/workouts'
|
||||
|
||||
export const sportColors: Record<string, string> = {
|
||||
'Cycling (Sport)': '#55A8A3',
|
||||
@ -9,6 +10,12 @@ export const sportColors: Record<string, string> = {
|
||||
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 sportALabel = a.label.toLowerCase()
|
||||
const sportBLabel = b.label.toLowerCase()
|
||||
@ -27,3 +34,9 @@ export const translateSports = (
|
||||
label: t(`sports.${sport.label}.LABEL`),
|
||||
}))
|
||||
.sort(sortSports)
|
||||
|
||||
export const getSportImg = (workout: IWorkout, sports: ISport[]): string => {
|
||||
return sports
|
||||
.filter((sport) => sport.id === workout.sport_id)
|
||||
.map((sport) => sport.img)[0]
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import {
|
||||
IWorkout,
|
||||
IWorkoutApiChartData,
|
||||
IWorkoutChartData,
|
||||
TCoordinates,
|
||||
@ -42,3 +43,27 @@ export const getDatasets = (
|
||||
|
||||
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
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { assert } from 'chai'
|
||||
|
||||
import createI18n from '@/i18n'
|
||||
import { getDatasets } from '@/utils/workouts'
|
||||
import { getDatasets, getDonutDatasets } from '@/utils/workouts'
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
Loading…
Reference in New Issue
Block a user