mirror of
https://github.com/advplyr/audiobookshelf-app.git
synced 2026-08-31 23:57:15 +02:00
@@ -99,6 +99,11 @@ export default {
|
|||||||
text: 'Account',
|
text: 'Account',
|
||||||
to: '/account'
|
to: '/account'
|
||||||
})
|
})
|
||||||
|
items.push({
|
||||||
|
icon: 'equalizer',
|
||||||
|
text: 'User Stats',
|
||||||
|
to: '/stats'
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.$platform !== 'ios') {
|
if (this.$platform !== 'ios') {
|
||||||
@@ -162,4 +167,4 @@ export default {
|
|||||||
this.show = false
|
this.show = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-96 my-6 mx-auto">
|
||||||
|
<h1 class="text-2xl mb-4 font-book">Minutes Listening <span class="text-white text-opacity-60 text-lg">(Last 7 days)</span></h1>
|
||||||
|
<div class="relative w-96 h-72">
|
||||||
|
<div class="absolute top-0 left-0">
|
||||||
|
<template v-for="lbl in yAxisLabels">
|
||||||
|
<div :key="lbl" :style="{ height: lineSpacing + 'px' }" class="flex items-center justify-end">
|
||||||
|
<p class="text-xs font-semibold">{{ lbl }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-for="n in 7">
|
||||||
|
<div :key="n" class="absolute pointer-events-none left-0 h-px bg-white bg-opacity-10" :style="{ top: n * lineSpacing - lineSpacing / 2 + 'px', width: '360px', marginLeft: '24px' }" />
|
||||||
|
|
||||||
|
<div :key="`dot-${n}`" class="absolute z-10" :style="{ left: points[n - 1].x + 'px', bottom: points[n - 1].y + 'px' }">
|
||||||
|
<div class="h-2 w-2 bg-yellow-400 hover:bg-yellow-300 rounded-full transform duration-150 transition-transform hover:scale-125" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-for="(line, index) in pointLines">
|
||||||
|
<div :key="`line-${index}`" class="absolute h-0.5 bg-yellow-400 origin-bottom-left pointer-events-none" :style="{ width: line.width + 'px', left: line.x + 'px', bottom: line.y + 'px', transform: `rotate(${line.angle}deg)` }" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="absolute -bottom-2 left-0 flex ml-6">
|
||||||
|
<template v-for="dayObj in last7Days">
|
||||||
|
<div :key="dayObj.date" :style="{ width: daySpacing + daySpacing / 14 + 'px' }">
|
||||||
|
<p class="text-sm font-book">{{ dayObj.dayOfWeek.slice(0, 3) }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between pt-12">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-center">Week Listening</p>
|
||||||
|
<p class="text-5xl font-semibold text-center" style="line-height: 0.85">{{ totalMinutesListeningThisWeek }}</p>
|
||||||
|
<p class="text-sm text-center">minutes</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-center">Daily Average</p>
|
||||||
|
<p class="text-5xl font-semibold text-center" style="line-height: 0.85">{{ averageMinutesPerDay }}</p>
|
||||||
|
<p class="text-sm text-center">minutes</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-center">Best Day</p>
|
||||||
|
<p class="text-5xl font-semibold text-center" style="line-height: 0.85">{{ mostListenedDay }}</p>
|
||||||
|
<p class="text-sm text-center">minutes</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-center">Days</p>
|
||||||
|
<p class="text-5xl font-semibold text-center" style="line-height: 0.85">{{ daysInARow }}</p>
|
||||||
|
<p class="text-sm text-center">in a row</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
listeningStats: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
// test: [111, 120, 4, 156, 273, 76, 12],
|
||||||
|
chartHeight: 288,
|
||||||
|
chartWidth: 384,
|
||||||
|
chartContentWidth: 360,
|
||||||
|
chartContentHeight: 268
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
yAxisLabels() {
|
||||||
|
var lbls = []
|
||||||
|
for (let i = 6; i >= 0; i--) {
|
||||||
|
lbls.push(i * this.yAxisFactor)
|
||||||
|
}
|
||||||
|
return lbls
|
||||||
|
},
|
||||||
|
chartContentMarginLeft() {
|
||||||
|
return this.chartWidth - this.chartContentWidth
|
||||||
|
},
|
||||||
|
chartContentMarginBottom() {
|
||||||
|
return this.chartHeight - this.chartContentHeight
|
||||||
|
},
|
||||||
|
lineSpacing() {
|
||||||
|
return this.chartHeight / 7
|
||||||
|
},
|
||||||
|
daySpacing() {
|
||||||
|
return this.chartContentWidth / 7
|
||||||
|
},
|
||||||
|
linePositions() {
|
||||||
|
var poses = []
|
||||||
|
for (let i = 7; i > 0; i--) {
|
||||||
|
poses.push(i * this.lineSpacing)
|
||||||
|
}
|
||||||
|
poses.push(0)
|
||||||
|
return poses
|
||||||
|
},
|
||||||
|
last7Days() {
|
||||||
|
var days = []
|
||||||
|
for (let i = 6; i >= 0; i--) {
|
||||||
|
var _date = this.$addDaysToToday(i * -1)
|
||||||
|
days.push({
|
||||||
|
dayOfWeek: this.$formatJsDate(_date, 'EEEE'),
|
||||||
|
date: this.$formatJsDate(_date, 'yyyy-MM-dd')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return days
|
||||||
|
},
|
||||||
|
last7DaysOfListening() {
|
||||||
|
var listeningDays = {}
|
||||||
|
var _index = 0
|
||||||
|
this.last7Days.forEach((dayObj) => {
|
||||||
|
listeningDays[_index++] = {
|
||||||
|
dayOfWeek: dayObj.dayOfWeek,
|
||||||
|
// minutesListening: this.test[_index - 1]
|
||||||
|
minutesListening: this.getMinutesListeningForDate(dayObj.date)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return listeningDays
|
||||||
|
},
|
||||||
|
mostListenedDay() {
|
||||||
|
var sorted = Object.values(this.last7DaysOfListening)
|
||||||
|
.map((dl) => ({ ...dl }))
|
||||||
|
.sort((a, b) => b.minutesListening - a.minutesListening)
|
||||||
|
return sorted[0].minutesListening
|
||||||
|
},
|
||||||
|
yAxisFactor() {
|
||||||
|
var factor = Math.ceil(this.mostListenedDay / 5)
|
||||||
|
|
||||||
|
if (factor > 25) {
|
||||||
|
// Use nearest multiple of 5
|
||||||
|
return Math.ceil(factor / 5) * 5
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(1, factor)
|
||||||
|
},
|
||||||
|
points() {
|
||||||
|
var data = []
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
var listeningObj = this.last7DaysOfListening[String(i)]
|
||||||
|
var minutesListening = listeningObj.minutesListening || 0
|
||||||
|
var yPercent = minutesListening / (this.yAxisFactor * 7)
|
||||||
|
data.push({
|
||||||
|
x: 4 + this.chartContentMarginLeft + (this.daySpacing + this.daySpacing / 14) * i,
|
||||||
|
y: this.chartContentMarginBottom + this.chartHeight * yPercent - 2
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
pointLines() {
|
||||||
|
var lines = []
|
||||||
|
for (let i = 1; i < 7; i++) {
|
||||||
|
var lastPoint = this.points[i - 1]
|
||||||
|
var nextPoint = this.points[i]
|
||||||
|
|
||||||
|
var x1 = lastPoint.x
|
||||||
|
var x2 = nextPoint.x
|
||||||
|
var y1 = lastPoint.y
|
||||||
|
var y2 = nextPoint.y
|
||||||
|
|
||||||
|
lines.push({
|
||||||
|
x: x1 + 4,
|
||||||
|
y: y1 + 2,
|
||||||
|
angle: this.getAngleBetweenPoints(x1, y1, x2, y2),
|
||||||
|
width: Math.sqrt(Math.pow(x1 - x2, 2) + Math.pow(y1 - y2, 2)) - 2
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
},
|
||||||
|
totalMinutesListeningThisWeek() {
|
||||||
|
var _total = 0
|
||||||
|
Object.values(this.last7DaysOfListening).forEach((listeningObj) => (_total += listeningObj.minutesListening))
|
||||||
|
return _total
|
||||||
|
},
|
||||||
|
averageMinutesPerDay() {
|
||||||
|
return Math.round(this.totalMinutesListeningThisWeek / 7)
|
||||||
|
},
|
||||||
|
daysInARow() {
|
||||||
|
var count = 0
|
||||||
|
while (true) {
|
||||||
|
var _date = this.$addDaysToToday(count * -1)
|
||||||
|
var datestr = this.$formatJsDate(_date, 'yyyy-MM-dd')
|
||||||
|
|
||||||
|
if (!this.listeningStatsDays[datestr] || this.listeningStatsDays[datestr] === 0) {
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
|
||||||
|
if (count > 9999) {
|
||||||
|
console.error('Overflow protection')
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
listeningStatsDays() {
|
||||||
|
return this.listeningStats ? this.listeningStats.days || [] : []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
getAngleBetweenPoints(cx, cy, ex, ey) {
|
||||||
|
var dy = ey - cy
|
||||||
|
var dx = ex - cx
|
||||||
|
var theta = Math.atan2(dy, dx)
|
||||||
|
theta *= 180 / Math.PI // convert to degrees
|
||||||
|
return theta * -1
|
||||||
|
},
|
||||||
|
getMinutesListeningForDate(date) {
|
||||||
|
if (!this.listeningStats || !this.listeningStats.days) return 0
|
||||||
|
return Math.round((this.listeningStats.days[date] || 0) / 60)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
+114
@@ -0,0 +1,114 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full h-full px-0 py-4 overflow-y-auto">
|
||||||
|
<h1 class="text-xl px-4">
|
||||||
|
Stats for <b>{{ username }}</b>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div class="flex text-center justify-center">
|
||||||
|
<div class="flex p-2">
|
||||||
|
<div class="px-3">
|
||||||
|
<p class="text-4xl md:text-5xl font-bold">{{ userItemsFinished.length }}</p>
|
||||||
|
<p class="font-book text-xs md:text-sm text-white text-opacity-80">Items Finished</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex p-2">
|
||||||
|
<div class="px-1">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex p-2">
|
||||||
|
<div class="px-1">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col md:flex-row overflow-hidden max-w-full">
|
||||||
|
<stats-daily-listening-chart :listening-stats="listeningStats" class="lg:scale-100 transform scale-90 px-0" />
|
||||||
|
<div class="w-80 my-6 mx-auto">
|
||||||
|
<div class="flex mb-4 items-center">
|
||||||
|
<h1 class="text-2xl font-book">Recent Sessions</h1>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
</div>
|
||||||
|
<p v-if="!mostRecentListeningSessions.length">No Listening Sessions</p>
|
||||||
|
<template v-for="(item, index) in mostRecentListeningSessions">
|
||||||
|
<div :key="item.id" class="w-full py-0.5">
|
||||||
|
<div class="flex items-center mb-1">
|
||||||
|
<p class="text-sm font-book text-white text-opacity-70 w-8">{{ index + 1 }}. </p>
|
||||||
|
<div class="w-56">
|
||||||
|
<p class="text-sm font-book text-white text-opacity-80 truncate">{{ item.mediaMetadata ? item.mediaMetadata.title : '' }}</p>
|
||||||
|
<p class="text-xs text-white text-opacity-50">{{ $dateDistanceFromNow(item.updatedAt) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<div class="w-18 text-right">
|
||||||
|
<p class="text-sm font-bold">{{ $elapsedPretty(item.timeListening) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
listeningStats: null,
|
||||||
|
windowWidth: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
currentLibraryId(newVal) {
|
||||||
|
if (newVal) {
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
user() {
|
||||||
|
return this.$store.state.user.user
|
||||||
|
},
|
||||||
|
username() {
|
||||||
|
return this.user ? this.user.username : ''
|
||||||
|
},
|
||||||
|
currentLibraryId() {
|
||||||
|
return this.$store.state.libraries.currentLibraryId
|
||||||
|
},
|
||||||
|
userMediaProgress() {
|
||||||
|
return this.user ? this.user.mediaProgress : []
|
||||||
|
},
|
||||||
|
userItemsFinished() {
|
||||||
|
return this.userMediaProgress.filter((lip) => !!lip.isFinished)
|
||||||
|
},
|
||||||
|
mostRecentListeningSessions() {
|
||||||
|
if (!this.listeningStats) return []
|
||||||
|
return this.listeningStats.recentSessions || []
|
||||||
|
},
|
||||||
|
totalMinutesListening() {
|
||||||
|
if (!this.listeningStats) return 0
|
||||||
|
return Math.round(this.listeningStats.totalTime / 60)
|
||||||
|
},
|
||||||
|
totalDaysListened() {
|
||||||
|
if (!this.listeningStats) return 0
|
||||||
|
return Object.values(this.listeningStats.days).length
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async init() {
|
||||||
|
this.listeningStats = await this.$axios.$get(`/api/me/listening-stats`).catch((err) => {
|
||||||
|
console.error('Failed to load listening sesions', err)
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
console.log('Loaded user listening data', this.listeningStats)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
+16
-3
@@ -2,7 +2,7 @@ import Vue from 'vue'
|
|||||||
import { App } from '@capacitor/app'
|
import { App } from '@capacitor/app'
|
||||||
import { Dialog } from '@capacitor/dialog'
|
import { Dialog } from '@capacitor/dialog'
|
||||||
import { StatusBar, Style } from '@capacitor/status-bar';
|
import { StatusBar, Style } from '@capacitor/status-bar';
|
||||||
import { formatDistance, format } from 'date-fns'
|
import { formatDistance, format, addDays, isDate } from 'date-fns'
|
||||||
import { Capacitor } from '@capacitor/core';
|
import { Capacitor } from '@capacitor/core';
|
||||||
|
|
||||||
Vue.prototype.$eventBus = new Vue()
|
Vue.prototype.$eventBus = new Vue()
|
||||||
@@ -24,7 +24,20 @@ Vue.prototype.$formatDate = (unixms, fnsFormat = 'MM/dd/yyyy HH:mm') => {
|
|||||||
if (!unixms) return ''
|
if (!unixms) return ''
|
||||||
return format(unixms, fnsFormat)
|
return format(unixms, fnsFormat)
|
||||||
}
|
}
|
||||||
|
Vue.prototype.$formatJsDate = (jsdate, fnsFormat = 'MM/dd/yyyy HH:mm') => {
|
||||||
|
if (!jsdate || !isDate(jsdate)) return ''
|
||||||
|
return format(jsdate, fnsFormat)
|
||||||
|
}
|
||||||
|
Vue.prototype.$addDaysToToday = (daysToAdd) => {
|
||||||
|
var date = addDays(new Date(), daysToAdd)
|
||||||
|
if (!date || !isDate(date)) return null
|
||||||
|
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 === null) return 'Invalid Bytes'
|
if (isNaN(bytes) || bytes === null) return 'Invalid Bytes'
|
||||||
if (bytes === 0) {
|
if (bytes === 0) {
|
||||||
@@ -162,4 +175,4 @@ export default ({ store, app }) => {
|
|||||||
export {
|
export {
|
||||||
encode,
|
encode,
|
||||||
decode
|
decode
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user