Add:Listening sessions calendar heat map

This commit is contained in:
advplyr 2022-05-11 16:27:40 -05:00
parent 621444114f
commit 5a6867e98a
5 changed files with 300 additions and 7 deletions

View File

@ -127,6 +127,14 @@ input[type=number] {
border-top: 6px solid white; border-top: 6px solid white;
} }
.arrow-down-small {
width: 0;
height: 0;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 4px solid currentColor;
}
.triangle-right { .triangle-right {
width: 0; width: 0;
height: 0; height: 0;

View File

@ -0,0 +1,273 @@
<template>
<div id="heatmap" class="w-full">
<div class="mx-auto" :style="{ height: innerHeight + 160 + 'px', width: innerWidth + 52 + 'px' }" style="background-color: rgba(13, 17, 23, 0)">
<p class="mb-2 px-1 text-sm text-gray-200">{{ Object.values(daysListening).length }} listening sessions in the last year</p>
<div class="border border-opacity-25 rounded py-2 w-full" style="background-color: #232323" :style="{ height: innerHeight + 80 + 'px' }">
<div :style="{ width: innerWidth + 'px', height: innerHeight + 'px' }" class="ml-10 mt-5 absolute" @mouseover="mouseover" @mouseout="mouseout">
<div v-for="dayLabel in dayLabels" :key="dayLabel.label" :style="dayLabel.style" class="absolute top-0 left-0 text-gray-300">{{ dayLabel.label }}</div>
<div v-for="monthLabel in monthLabels" :key="monthLabel.id" :style="monthLabel.style" class="absolute top-0 left-0 text-gray-300">{{ monthLabel.label }}</div>
<div v-for="(block, index) in data" :key="block.dateString" :style="block.style" :data-index="index" class="absolute top-0 left-0 h-2.5 w-2.5 rounded-sm" />
<div class="flex py-2 px-4" :style="{ marginTop: innerHeight + 'px' }">
<div class="flex-grow" />
<p style="font-size: 10px; line-height: 10px" class="text-gray-400 px-1">Less</p>
<div v-for="block in legendBlocks" :key="block.id" :style="block.style" class="h-2.5 w-2.5 rounded-sm" style="margin-left: 1.5px; margin-right: 1.5px" />
<p style="font-size: 10px; line-height: 10px" class="text-gray-400 px-1">More</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
daysListening: {
type: Object,
default: () => {}
}
},
data() {
return {
contentWidth: 0,
maxInnerWidth: 0,
innerHeight: 13 * 7,
blockWidth: 13,
data: [],
monthLabels: [],
tooltipEl: null,
tooltipTextEl: null,
tooltipArrowEl: null,
showingTooltipIndex: -1,
outlineColors: ['rgba(27, 31, 35, 0.06)', 'rgba(255,255,255,0.03)'],
bgColors: ['rgb(45,45,45)', 'rgb(14, 68, 41)', 'rgb(0, 109, 50)', 'rgb(38, 166, 65)', 'rgb(57, 211, 83)']
// GH Colors
// outlineColors: ['rgba(27, 31, 35, 0.06)', 'rgba(255,255,255,0.05)'],
// bgColors: ['rgb(22, 27, 34)', 'rgb(14, 68, 41)', 'rgb(0, 109, 50)', 'rgb(38, 166, 65)', 'rgb(57, 211, 83)']
}
},
computed: {
weeksToShow() {
return Math.min(52, Math.floor(this.maxInnerWidth / this.blockWidth) - 1)
},
innerWidth() {
return (this.weeksToShow + 1) * 13
},
daysToShow() {
return this.weeksToShow * 7 + this.dayOfWeekToday
},
dayOfWeekToday() {
return new Date().getDay()
},
firstWeekStart() {
return this.$addDaysToToday(-this.daysToShow)
},
dayLabels() {
return [
{
label: 'Mon',
style: {
transform: `translate(${-25}px, ${13}px)`,
lineHeight: '10px',
fontSize: '10px'
}
},
{
label: 'Wed',
style: {
transform: `translate(${-25}px, ${13 * 3}px)`,
lineHeight: '10px',
fontSize: '10px'
}
},
{
label: 'Fri',
style: {
transform: `translate(${-25}px, ${13 * 5}px)`,
lineHeight: '10px',
fontSize: '10px'
}
}
]
},
legendBlocks() {
return [
{
id: 'legend-0',
style: `background-color:${this.bgColors[0]};outline:1px solid ${this.outlineColors[0]};outline-offset:-1px;`
},
{
id: 'legend-1',
style: `background-color:${this.bgColors[1]};outline:1px solid ${this.outlineColors[1]};outline-offset:-1px;`
},
{
id: 'legend-2',
style: `background-color:${this.bgColors[2]};outline:1px solid ${this.outlineColors[1]};outline-offset:-1px;`
},
{
id: 'legend-3',
style: `background-color:${this.bgColors[3]};outline:1px solid ${this.outlineColors[1]};outline-offset:-1px;`
},
{
id: 'legend-4',
style: `background-color:${this.bgColors[4]};outline:1px solid ${this.outlineColors[1]};outline-offset:-1px;`
}
]
}
},
methods: {
destroyTooltip() {
if (this.tooltipEl) this.tooltipEl.remove()
this.tooltipEl = null
this.showingTooltipIndex = -1
},
createTooltip() {
const tooltip = document.createElement('div')
tooltip.className = 'absolute top-0 left-0 rounded bg-gray-500 text-white p-2 text-white max-w-xs pointer-events-none'
tooltip.style.display = 'none'
tooltip.id = 'heatmap-tooltip'
const tooltipText = document.createElement('p')
tooltipText.innerText = 'Tooltip'
tooltipText.style.fontSize = '10px'
tooltipText.style.lineHeight = '10px'
tooltip.appendChild(tooltipText)
const tooltipArrow = document.createElement('div')
tooltipArrow.className = 'text-gray-500 arrow-down-small absolute -bottom-1 left-0 right-0 mx-auto'
tooltip.appendChild(tooltipArrow)
this.tooltipEl = tooltip
this.tooltipTextEl = tooltipText
this.tooltipArrowEl = tooltipArrow
document.body.appendChild(this.tooltipEl)
},
showTooltip(index, block, rect) {
if (this.tooltipEl && this.showingTooltipIndex === index) return
if (!this.tooltipEl) {
this.createTooltip()
}
this.showingTooltipIndex = index
this.tooltipEl.style.display = 'block'
this.tooltipTextEl.innerHTML = block.value ? `<strong>${block.value} minutes listening</strong> on ${block.datePretty}` : `No listening sessions on ${block.datePretty}`
const calculateRect = this.tooltipEl.getBoundingClientRect()
const w = calculateRect.width / 2
var left = rect.x - w
var offsetX = 0
if (left < 0) {
offsetX = Math.abs(left)
left = 0
} else if (rect.x + w > window.innerWidth - 10) {
offsetX = window.innerWidth - 10 - (rect.x + w)
left += offsetX
}
this.tooltipEl.style.transform = `translate(${left}px, ${rect.y - 32}px)`
this.tooltipArrowEl.style.transform = `translate(${5 - offsetX}px, 0px)`
},
hideTooltip() {
if (this.showingTooltipIndex >= 0 && this.tooltipEl) {
this.tooltipEl.style.display = 'none'
this.showingTooltipIndex = -1
}
},
mouseover(e) {
if (isNaN(e.target.dataset.index)) {
this.hideTooltip()
return
}
var block = this.data[e.target.dataset.index]
var rect = e.target.getBoundingClientRect()
this.showTooltip(e.target.dataset.index, block, rect)
},
mouseout(e) {
this.hideTooltip()
},
buildData() {
this.data = []
var maxValue = 0
var minValue = 0
Object.values(this.daysListening).forEach((val) => {
if (val > maxValue) maxValue = val
if (!minValue || val < minValue) minValue = val
})
const range = maxValue - minValue + 0.01
for (let i = 0; i < this.daysToShow + 1; i++) {
const col = Math.floor(i / 7)
const row = i % 7
const date = i === 0 ? this.firstWeekStart : this.$addDaysToDate(this.firstWeekStart, i)
const dateString = this.$formatJsDate(date, 'yyyy-MM-dd')
const datePretty = this.$formatJsDate(date, 'MMM d, yyyy')
const monthString = this.$formatJsDate(date, 'MMM')
const value = this.daysListening[dateString] || 0
const x = col * 13
const y = row * 13
var bgColor = this.bgColors[0]
var outlineColor = this.outlineColors[0]
if (value) {
outlineColor = this.outlineColors[1]
var percentOfAvg = (value - minValue) / range
var bgIndex = Math.floor(percentOfAvg * 4) + 1
bgColor = this.bgColors[bgIndex] || 'red'
}
this.data.push({
date,
dateString,
datePretty,
monthString,
dayOfMonth: Number(dateString.split('-').pop()),
yearString: dateString.split('-').shift(),
value,
col,
row,
style: `transform:translate(${x}px,${y}px);background-color:${bgColor};outline:1px solid ${outlineColor};outline-offset:-1px;`
})
}
console.log('Data', this.data)
this.monthLabels = []
var lastMonth = null
for (let i = 0; i < this.data.length; i++) {
if (this.data[i].monthString !== lastMonth) {
const weekOfMonth = Math.floor(this.data[i].dayOfMonth / 7)
if (weekOfMonth <= 2) {
this.monthLabels.push({
id: this.data[i].dateString + '-ml',
label: this.data[i].monthString,
style: {
transform: `translate(${this.data[i].col * 13}px, -15px)`,
lineHeight: '10px',
fontSize: '10px'
}
})
lastMonth = this.data[i].monthString
}
}
}
},
init() {
const heatmapEl = document.getElementById('heatmap')
this.contentWidth = heatmapEl.clientWidth
this.maxInnerWidth = this.contentWidth - 52
this.buildData()
}
},
updated() {},
mounted() {
this.init()
},
beforeDestroy() {}
}
</script>

View File

@ -1,5 +1,5 @@
<template> <template>
<div id="page-wrapper" class="page p-6 overflow-y-auto relative" :class="streamLibraryItem ? 'streaming' : ''"> <div id="page-wrapper" class="page p-2 md:p-6 overflow-y-auto relative" :class="streamLibraryItem ? 'streaming' : ''">
<app-config-side-nav :is-open.sync="sideDrawerOpen" /> <app-config-side-nav :is-open.sync="sideDrawerOpen" />
<div class="configContent" :class="`page-${currentPage}`"> <div class="configContent" :class="`page-${currentPage}`">
<div v-show="isMobile" class="w-full pb-4 px-2 flex border-b border-white border-opacity-10 mb-2"> <div v-show="isMobile" class="w-full pb-4 px-2 flex border-b border-white border-opacity-10 mb-2">

View File

@ -2,7 +2,7 @@
<div> <div>
<div class="flex justify-center"> <div class="flex justify-center">
<div class="flex p-2"> <div class="flex p-2">
<svg class="h-14 w-14 md:h-18 md:w-18" viewBox="0 0 24 24"> <svg class="hidden sm:block h-14 w-14 lg:h-18 lg:w-18" viewBox="0 0 24 24">
<path <path
fill="currentColor" fill="currentColor"
d="M19 1L14 6V17L19 12.5V1M21 5V18.5C19.9 18.15 18.7 18 17.5 18C15.8 18 13.35 18.65 12 19.5V6C10.55 4.9 8.45 4.5 6.5 4.5C4.55 4.5 2.45 4.9 1 6V20.65C1 20.9 1.25 21.15 1.5 21.15C1.6 21.15 1.65 21.1 1.75 21.1C3.1 20.45 5.05 20 6.5 20C8.45 20 10.55 20.4 12 21.5C13.35 20.65 15.8 20 17.5 20C19.15 20 20.85 20.3 22.25 21.05C22.35 21.1 22.4 21.1 22.5 21.1C22.75 21.1 23 20.85 23 20.6V6C22.4 5.55 21.75 5.25 21 5M10 18.41C8.75 18.09 7.5 18 6.5 18C5.44 18 4.18 18.19 3 18.5V7.13C3.91 6.73 5.14 6.5 6.5 6.5C7.86 6.5 9.09 6.73 10 7.13V18.41Z" d="M19 1L14 6V17L19 12.5V1M21 5V18.5C19.9 18.15 18.7 18 17.5 18C15.8 18 13.35 18.65 12 19.5V6C10.55 4.9 8.45 4.5 6.5 4.5C4.55 4.5 2.45 4.9 1 6V20.65C1 20.9 1.25 21.15 1.5 21.15C1.6 21.15 1.65 21.1 1.75 21.1C3.1 20.45 5.05 20 6.5 20C8.45 20 10.55 20.4 12 21.5C13.35 20.65 15.8 20 17.5 20C19.15 20 20.85 20.3 22.25 21.05C22.35 21.1 22.4 21.1 22.5 21.1C22.75 21.1 23 20.85 23 20.6V6C22.4 5.55 21.75 5.25 21 5M10 18.41C8.75 18.09 7.5 18 6.5 18C5.44 18 4.18 18.19 3 18.5V7.13C3.91 6.73 5.14 6.5 6.5 6.5C7.86 6.5 9.09 6.73 10 7.13V18.41Z"
@ -15,7 +15,9 @@
</div> </div>
<div class="flex p-2"> <div class="flex p-2">
<span class="material-icons-outlined" style="font-size: 4.1rem">event</span> <div class="hidden sm:block">
<span class="hidden sm:block material-icons-outlined text-5xl lg:text-6xl">event</span>
</div>
<div class="px-1"> <div class="px-1">
<p class="text-4xl md:text-5xl font-bold">{{ totalDaysListened }}</p> <p class="text-4xl md:text-5xl font-bold">{{ totalDaysListened }}</p>
<p class="font-book text-xs md:text-sm text-white text-opacity-80">Days Listened</p> <p class="font-book text-xs md:text-sm text-white text-opacity-80">Days Listened</p>
@ -23,15 +25,17 @@
</div> </div>
<div class="flex p-2"> <div class="flex p-2">
<span class="material-icons-outlined" style="font-size: 4.1rem">watch_later</span> <div class="hidden sm:block">
<span class="material-icons-outlined text-5xl lg:text-6xl">watch_later</span>
</div>
<div class="px-1"> <div class="px-1">
<p class="text-4xl md:text-5xl font-bold">{{ totalMinutesListening }}</p> <p class="text-4xl md:text-5xl font-bold">{{ totalMinutesListening }}</p>
<p class="font-book text-xs md:text-sm text-white text-opacity-80">Minutes Listening</p> <p class="font-book text-xs md:text-sm text-white text-opacity-80">Minutes Listening</p>
</div> </div>
</div> </div>
</div> </div>
<div class="flex flex-col md:flex-row"> <div class="flex flex-col md:flex-row overflow-hidden max-w-full">
<stats-daily-listening-chart :listening-stats="listeningStats" /> <stats-daily-listening-chart :listening-stats="listeningStats" class="origin-top-left transform scale-75 lg:scale-100" />
<div class="w-80 my-6 mx-auto"> <div class="w-80 my-6 mx-auto">
<h1 class="text-2xl mb-4 font-book">Recent Listening Sessions</h1> <h1 class="text-2xl mb-4 font-book">Recent Listening Sessions</h1>
<p v-if="!mostRecentListeningSessions.length">No Listening Sessions</p> <p v-if="!mostRecentListeningSessions.length">No Listening Sessions</p>
@ -52,6 +56,8 @@
</template> </template>
</div> </div>
</div> </div>
<stats-heatmap v-if="listeningStats" :days-listening="listeningStats.days" class="my-2" />
</div> </div>
</template> </template>
@ -59,7 +65,8 @@
export default { export default {
data() { data() {
return { return {
listeningStats: null listeningStats: null,
windowWidth: 0
} }
}, },
watch: { watch: {

View File

@ -23,6 +23,11 @@ Vue.prototype.$addDaysToToday = (daysToAdd) => {
if (!date || !isDate(date)) return null if (!date || !isDate(date)) return null
return date return date
} }
Vue.prototype.$addDaysToDate = (jsdate, daysToAdd) => {
var date = addDays(jsdate, daysToAdd)
if (!date || !isDate(date)) return null
return date
}
Vue.prototype.$bytesPretty = (bytes, decimals = 2) => { Vue.prototype.$bytesPretty = (bytes, decimals = 2) => {
if (isNaN(bytes) || bytes == 0) { if (isNaN(bytes) || bytes == 0) {