Skip to content

Instantly share code, notes, and snippets.

@alhankeser
Created September 14, 2019 16:40
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save alhankeser/bfe3ca340e1589a3088719bf4a870fc2 to your computer and use it in GitHub Desktop.
Save alhankeser/bfe3ca340e1589a3088719bf4a870fc2 to your computer and use it in GitHub Desktop.
Zwift Report live race view
<template>
<div class="container-fluid">
<navigation :race="race"/>
<transition name="fade">
<div v-if="loading" style="position:fixed" class="animated yt-loader"></div>
</transition>
<div class="row content">
<div class="race-header">
<div class="relative-start">
<span class="badge live-alert" style="font-style:normal;" v-if="isLive(race)">LIVE!</span>
{{ isPast(race) ? 'Started' : 'Starts' }}
{{ race.eventStart | momentRelativeTime }}:
{{ race.eventStart | momentDate }}
{{ race.eventStart | momentTime }}
{{ defaultTimeZone }}
<a :href="'https://www.zwiftpower.com/events.php?zid=' + race.id" target="_blank">Final Results on ZP</a>
</div>
<div style="background: #ffff; padding: 1rem;" v-if="showHelp"><strong>Notes:</strong> This is not an official Zwift app. Gaps/groups may be wrong for brief moments. If you don't see yourself or a rider, it's because they're either finished with the race or the Zwift API is not being co-operative. Red, green, yellow, orange, purple designate categories. Grey indicates no HR and/or zPower riders. <strong>Pro Tips:</strong> Click/tap a rider name to highlight for easier following. Zoom out with your browser to see more of the race.<br><a href="javascript:void(0);" @click="hideHelp()">Close</a></div>
<!-- <div class="search-box">
<input @keyup="filterRiders" v-model="nameFilter" type="text">
</div> -->
</div>
<div style="font-size:2rem; padding: 2rem;" v-if="!riders || riders.length < 1">
<div v-if="false">
<p>This event has ended. See <a :href="'https://www.zwiftpower.com/events.php?zid=' + race.id" target="_blank">final results on ZP</a></p>
<router-link class="btn btn-primary" :to="'/'">See Live Races</router-link>
</div>
<div v-else>
<p>The event is currently not live, data is still loading, or something is broken.</p>
<strong>This page will refresh itself if results start to come in.</strong>
</div>
</div>
<div class="packs">
<transition-group name="flip-list">
<div class="pack" v-for="pack in riders" :key="pack[0].pack_id">
<div class="rider-count f-black">{{pack.length > 1 ? pack.length + ' Riders' : 'Solo'}}</div>
<div class="pack-data">
<div><small>{{ getPackDistance(pack) }}</small>
<small>{{ getPackPower(pack) }}<span v-if="false" class="red-text">{{ '+' + decimalToTimeGap(pack[0].timeGap) }}</span></small></div>
<div v-if="getPackGap(pack)" class="red-text gap" v-html="'+' + decimalToTimeGap(pack[0].timeGap) + ' <small style=\'font-weight\:initial\'>(+' + decimalToTimeGap(pack[0].pack_time_gap_to_prev) + ')</small>'"></div>
<div v-if="false" v-html="decimalToTimeGap(pack[0].pack_time_gap_to_prev)"></div>
</div>
<div class="pack-container">
<div class="rider" v-for="rider in pack" @click="star(rider)" :class="getRiderClass(rider)" :style="pack.length > 20 ? 'padding: 0.4rem 0.2rem;' : '' " :key="rider.id">
<div class="name" v-html="rider.lastName"></div>
<i class="fas fa-heart"></i>
<i class="fas fa-motorcycle"></i>
<!-- <div class="rider-data" v-html="rider.progress/1000"></div> -->
</div>
</div>
</div>
</transition-group>
</div>
</div>
<div id="scrollController">
<a class="btn btn-default" href="javascript:void(0)" @click="toggleScroller()">Auto Scroll Left-Right</a>
</div>
</div>
</template>
<script>
const zrLog = function(data) {
true ? console.log(data) : false;
}
const firebase = require("firebase");
require("firebase/firestore");
var config = {
apiKey: "XXX",
authDomain: "XXX.firebaseapp.com",
projectId: 'XXX'`
};
firebase.initializeApp(config);
var db = firebase.firestore();
var scroller;
import Navigation from './Navigation'
import axios from 'axios'
import moment from 'moment'
import momentDurationFormatSetup from 'moment-duration-format'
import { setTimeout } from 'timers';
momentDurationFormatSetup(moment)
var storageKey = function(key) {
return '__zwiftReport_' + window.location.hash.split('/').pop(1) + '_' + key
}
var starredStorageKey = '__zwiftReport_starred';
var starred = JSON.parse(localStorage.getItem(starredStorageKey)) || [];
export default {
name: 'Race',
components: {
navigation: Navigation
},
data() {
return {
now: moment(),
riders: false,
nameFilter: '',
starred: starred,
race: {},
loading: true,
showHelp: localStorage.getItem('__zr_help') === 'hide' ? false : true,
defaultTimeZone: String(moment()._d).split(' ').pop(1),
broadcast: window.location.hash.split('?')[1] ? true : false,
isScrolling: false,
scrollDirection: 'right'
}
},
methods: {
hideHelp() {
this.showHelp = false
localStorage.setItem('__zr_help', 'hide');
},
filterRiders () {
for (var pack in this.riders) {
zrLog(this.riders[pack])
this.riders[pack] = this.riders[pack].filter(rider => {
zrLog(rider.lastName, rider.lastName.toLowerCase().search(this.nameFilter.toLowerCase()) > -1)
return rider.lastName.toLowerCase().search(this.nameFilter.toLowerCase()) > -1
})
}
},
getRiderClass (rider) {
return {
'cat-a': rider.categoryLabel === 1,
'cat-b': rider.categoryLabel === 2,
'cat-c': rider.categoryLabel === 3,
'cat-d': rider.categoryLabel === 4,
'cat-e': rider.categoryLabel === 5,
// 'orange': rider.heartrate > 140,
// 'red' : rider.heartrate > 169,
// 'boom': rider.heartrate > 169, //(220 - rider.age) * 0.9,
'nohr': rider.heartrate === 0,
'zpower': rider.powerSourceModel === 'zPower',
'faded' : rider.heartrate === 0 || rider.powerSourceModel === 'zPower',
'starred': this.isStarred(rider)
}
},
refreshData () {
zrLog('refreshData')
var docRef = db.collection('races').doc(this.$route.params.id)
var $this = this;
docRef.get().then(function(doc) {
if (doc.exists) {
zrLog('Doc exists')
$this.updateRiders($this.organizeRiders(doc.data()))
}
else {
zrLog('Doc does not exist')
$this.getRiders($this.$route.params.id)
}
})
$this.getRiders($this.$route.params.id)
},
updateRiders (data) {
zrLog('updateRiders')
this.riders = data
},
load () {
var $this = this
this.loading = true
setTimeout(function() {
$this.loading = false
}, 3000)
},
storeRiders (data) {
if (data !== [] && Object.keys(data).length > 0 && typeof(data) === 'object') {
// var timestamp = this.getTimestamp();
// var dataObject = {}
// dataObject[timestamp] = {time: timestamp, riders: data}
db.collection('races').doc(this.$route.params.id).set(data)
.catch(function(error) {
console.error("Error adding document: ", error)
});
zrLog('storeRiders')
}
else {
this.updateRiders(false)
zrLog('Tried storeRiders but failed')
}
},
organizeRiders (riders) {
zrLog('organizeRiders')
for (var pack in riders) {
var notStarredRiders = []
var starredRiders = []
riders[pack].forEach(rider => {
this.isStarred(rider) ? starredRiders.push(rider) : notStarredRiders.push(rider)
})
riders[pack] = starredRiders.concat(notStarredRiders)
}
return riders
},
getRiders (id) {
var $this = this
var riders
zrLog('getRiders called')
axios.get('/api/races/' + id + '/live',{'timeout': 360000 }).then(response => {
zrLog('getRiders got api response')
riders = response['data']
riders = this.organizeRiders(riders)
this.updateRiders(riders)
this.storeRiders(riders)
})
},
getRace (id) {
var $this = this;
zrLog('getRace called')
axios.get('/api/races/' + id).then(response => {
$this.race = response['data'];
});
this.$route.params['raceName'] = this.race.name
},
//
getMoment (toMoment) {
return moment(toMoment)
},
//
getTimestamp () {
var date = new Date
return date.getTime() - date.getTimezoneOffset()
},
unixToMoment (unixDate) {
return moment.unix(unixDate)
},
timeDifference (race) {
return moment(race.eventStart).diff(this.now)
},
updateNow () {
this.now = moment()
setTimeout(this.updateNow, 1000)
},
isPast (race) {
return this.timeDifference(race) < 0
},
isLive (race) {
return this.isPast(race) === 0 // && Number(race.fin)
},
calculateGap (gapInSeconds) {
if (Number(gapInSeconds) === 0) {
return 'At the front'
}
var duration = moment.duration(gapInSeconds, 'seconds');
var formatted = duration.format("\+ h [hrs], m [min], s [sec]");
return formatted;
},
roundTo (number, precision) {
var factor = Math.pow(10, precision);
return Math.round(number * factor) / factor;
},
getPackGap (pack) {
var gap = pack[0].pack_gap
if (gap > 0) {
if (gap > 1000) {
return '+' + this.roundTo(gap/1000, 2) + 'km'
}
else {
return '+' + this.roundTo(gap, 1) + 'm'
}
}
else {
return false
}
},
isStarred (rider) {
return this.starred.indexOf(rider.id) > -1
},
star (rider) {
if (this.isStarred(rider)) {
this.starred.splice(this.starred.indexOf(rider.id), 1)
}
else {
this.starred.push(rider.id)
}
localStorage.setItem(starredStorageKey, JSON.stringify(this.starred))
},
getPackPower(pack) {
return this.roundTo(pack[0].power/(pack[0].weight/1000),2) + 'w/kg'
},
getPackDistance(pack) {
return this.roundTo(pack[0].distance/1000, 2) + 'km'
},
getDistance(distance) {
return this.roundTo(distance/1000, 2) + 'km'
},
decimalToTimeGap(gap) {
return gap < 1440 ? moment.duration(gap, "minutes").format() : '&#x221e;'
},
toggleScroller() {
var $this = this;
if (!this.isScrolling) {
scroller = setInterval(function() {
$this.scroll()
}, 5000)
this.isScrolling = true;
}
else {
window.clearInterval(scroller);
this.isScrolling = false;
}
},
scroll() {
console.log(this.remainingToScroll())
if (this.scrollDirection === 'right') {
this.remainingToScroll() > 0 ? window.scrollBy(window.innerWidth, 0) : this.scrollDirection = 'left'
}
if (this.scrollDirection === 'left') {
this.remainingToScroll() > 0 ? window.scrollBy(-10, 0) : this.scrollDirection = 'right'
}
},
remainingToScroll() {
if (this.scrollDirection === 'right') {
return document.body["scrollWidth"] - window.scrollX - window.innerWidth
}
if (this.scrollDirection === 'left') {
return window.scrollX
}
}
},
filters: {
momentDate: function (date) {
return moment(date).format('MMM Do')
},
momentTime: function (date) {
return moment(date).format('h:mm a')
},
momentRelativeTime: function (date) {
return moment(date).fromNow()
}
},
mounted() {
var $this = this;
if (this.$route.name === 'Race') {
this.getRace(this.$route.params.id)
$this.getRiders($this.$route.params.id)
this.refreshData()
}
var refreshResults;
var resetInterval = function(ms) {
zrLog('resetInterval interval set at ' + ms)
refreshResults = setInterval(function() {
zrLog('refreshResults called')
if ($this.$route.name === 'Race' &&
Number($this.$route.params.id) === Number(window.location.hash.split('/').pop(1)) ) {
zrLog('refreshResults invoking getRiders')
$this.getRiders($this.$route.params.id)
}
else {
zrLog('refreshResults interval cleared')
clearInterval(refreshResults)
}
}, ms)
}
resetInterval(20000);
db.collection('races').doc(this.$route.params.id).onSnapshot(function(doc) {
zrLog('db listener called')
$this.load()
if (!doc.metadata.hasPendingWrites) {
// var dbData = db.collection('races/'+$this.$route.params.id).limit(1)
// zrLog(dbData.data());
var riders = $this.organizeRiders(doc.data())
$this.updateRiders(riders)
zrLog('this.riders updated from db')
clearInterval(refreshResults)
zrLog('refreshResults cleared and reset')
resetInterval(20000)
}
});
this.updateNow()
// ga('set', 'page', '/races/' + this.$route.params.id);
// ga('send', 'pageview');
}
}
</script>
<style>
.flip-list-move {
transition: transform 1s !important;
}
.fade-enter-active, .fade-leave-active {
transition: opacity .5s;
}
.fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ {
opacity: 0;
}
.packs > span{
display: flex;
flex-flow: column wrap;
max-height: 80vh;
justify-content: flex-start;
width: min-content;
padding-left: 2rem;
}
.pack {
display: flex;
flex-direction: column;
margin: 0.8rem 0.4rem;
flex-grow: 0;
flex-shrink: 0;
width: 120px;
background: #fff;
box-shadow: 0 1px 3px rgba(0,0,0,.12), 0 1px 2px rgba(0,0,0,.24);
transition: all .3s cubic-bezier(.25,.8,.25,1);
}
.pack-data {
flex-shrink: 0;
display: flex;
flex-wrap: wrap;
justify-content: space-between;
font-size: 1.4rem;
}
.pack-data, .rider-count {
padding: 0.6rem;
}
.rider-count {
background: #f9f9f9;
}
.gap, .rider-count {
font-weight: 700;
font-size: 1.6rem;
}
.red-text {
color: red;
}
.pack-container > span, .pack-container {
display: flex;
flex-wrap: wrap;
}
.race-container, .pack-container {
-webkit-transition: all 0.3s cubic-bezier(.25,.8,.25,1);
transition: all 0.3s cubic-bezier(.25,.8,.25,1);
}
.race-container .name {
font-size: 3rem;
font-weight: 700;
line-height: 1;
}
.bg-success {
background-color: #dff0d8 !important;
}
.live-alert {
background-color: #57c44d;
}
.rider {
padding: 0.8rem 0.2rem;
/* text-align: center; */
margin: 1px 0 0 0;
background: #ccc;
color: #000;
overflow: hidden;
flex-grow: 1;
flex-basis: 100%;
}
.rider:hover {
opacity: 0.8;
cursor: pointer;
box-shadow: 0 1px 3px rgba(0,0,0,.12), 0 1px 2px rgba(0,0,0,.24);
}
.pack-data > * {
line-height: 1.2;
}
.rider .name {
font-size: 1.4rem;
line-height: 1;
text-transform: capitalize;
color: #fff;
}
.race-header {
/* padding: 1rem 2rem; */
}
.race-header .name {
font-size: 3rem;
font-weight: 700;
}
.utilities .back-link {
flex-basis: 18%;
}
.cat-a, .cat-b, .cat-c, .cat-d, .cat-e {
opacity: 0.9;
}
.orange {
background-color: orange;
}
.cat-a, .red {
background-color: #fc4119;
}
.cat-b, .green {
background-color: #58c34e;
}
.cat-c, .blue {
background-color: #3ec0e9;
}
.cat-d, .yellow {
background-color: #fccf0b;
}
.cat-e {
background-color: #943e5e;
}
.cat-a.faded {
background-color: rgba(252, 65, 25, .4);
}
.cat-b.faded {
background-color: rgba(88, 195, 78, .4);
}
.cat-c.faded {
background-color: rgba(62, 192, 233, .4);
}
.cat-d.faded {
background-color: rgba(252, 207, 11, .4);
}
.cat-e.faded {
background-color: rgba(148, 62, 94, .4);
}
.faded.rider, .faded.rider .name {
color: #5d5d5d;
}
.rider.faded {
display: flex;
}
.rider svg {
display: none;
height: 12px;
width: 12px;
}
.rider.nohr .fa-heart {
display: initial;
}
.rider.zpower .fa-motorcycle {
display: initial;
}
/* .zpower, .nohr {
opacity: .4;
} */
.boom {
-webkit-animation: blowing-up 1s infinite; /* Safari 4+ */
-moz-animation: blowing-up 1s infinite; /* Fx 5+ */
-o-animation: blowing-up 1s infinite; /* Opera 12+ */
animation: blowing-up 1s infinite; /* IE 10+, Fx 29+ */
}
@-webkit-keyframes blowing-up {
0%, 49% {
opacity: 0.7;
}
50%, 100% {
opacity: 1;
}
}
.rider.starred .name {
font-size: 2rem;
font-weight: 700;
}
.rider.starred {
border: 6px solid #ce57c4;
}
#scrollController {
position: fixed;
bottom: 20px;
left: 20px;
display: none;
}
.animated {
-webkit-animation-duration: 3s;
animation-duration: 3s;
-webkit-animation-fill-mode: both;
animation-fill-mode: both;
-moz-user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
}
.yt-loader {
-webkit-animation-name: horizontalProgressBar;
animation-name: horizontalProgressBar;
-webkit-animation-timing-function: ease;
animation-timing-function: ease;
background: #57c44d;
height: 3px;
left: 0;
position: fixed;
top: 0;
width: 0%;
z-index: 9999;
}
@keyframes horizontalProgressBar
{
0% {width: 0%;}
25% {width: 22%;}
50% {width: 55%;}
75% {width: 83%;}
100% {width:100%;}
}
@-webkit-keyframes horizontalProgressBar /* Safari and Chrome */
{
0% {width: 0%;}
25% {width: 22%;}
50% {width: 55%;}
75% {width: 83%;}
100% {width:100%;}
}
@media only screen and (max-width: 1080px) {
}
@media only screen and (max-width: 800px) {
.race-header .name {
line-height: 1.2;
}
.rider {
padding: 1rem 0.2rem;
}
}
@media only screen and (max-height: 200px) {
.race-header {
display: none;
}
.pack-data {
min-width: 60px;
}
.rider-count {
min-width: 78px;
}
.pack-data small:last-child {
display: none;
}
.gap {
display: none;
}
.pack {
flex-direction: column;
width: initial;
align-items: flex-start;
min-height: 45px;
}
.pack-container {
flex-wrap: nowrap;
}
.packs > span {
width: initial;
padding-left: initial;
}
.rider .name {
white-space: nowrap;
}
.pack > * {
min-width: 78px;
}
.rider.starred .name {
font-size: initial;
font-weight: initial;
}
body {
background: #000;
}
#scrollController {
display: block;
}
.relative-start {
display: none;
}
}
</style>
<style scoped>
.relative-start {
font-style: italic;
color: #737373;
position: fixed;
bottom: 0;
right: 0;
background: #fff;
padding: .5rem;
font-size: 11px;
z-index: 10;
}
</style>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment