mirror of
https://github.com/KevinMidboe/seasoned.git
synced 2026-04-24 16:53:37 +00:00
* Add activity page components and Tautulli stats integration - Add StatsOverview component for watch statistics display - Add WatchHistory component for recent watch activity - Add useTautulliStats composable for Tautulli API integration - Components display total plays, watch time, movies/episodes watched - Support for fetching home stats and last watched content * Enhance Graph component with improved styling and options - Add wrapper div for better layout control - Update color scheme with modern palette (Indigo, Amber, Emerald) - Add Filler plugin for filled area charts - Improve bar chart styling with rounded corners - Add proper lifecycle cleanup with onBeforeUnmount - Enhance tooltip formatting for time and number values - Add deep watch for reactive data updates - Better TypeScript type safety with Chart.js types * Refactor ActivityPage with enhanced stats and visualizations - Integrate StatsOverview component for at-a-glance metrics - Add WatchHistory component for recent watch activity - Add hourly viewing patterns chart - Modernize UI with card-based layout - Improve controls styling with better labels and input handling - Remove authentication dependency (now handled by route guards) - Use useTautulliStats composable for data fetching - Add comprehensive watch statistics (total plays, hours, by media type) - Support for both plays and duration view modes * Improve Plex authentication check with cookie fallback - Add usePlexAuth composable import to routes - Enhance hasPlexAccount() to check cookies when Vuex store is empty - Fixes authentication check after page refreshes - Ensures activity page remains accessible with valid Plex auth
235 lines
5.4 KiB
Vue
235 lines
5.4 KiB
Vue
<template>
|
|
<div class="graph-wrapper">
|
|
<canvas ref="graphCanvas"></canvas>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, onMounted, watch, onBeforeUnmount } from "vue";
|
|
import {
|
|
Chart,
|
|
LineElement,
|
|
BarElement,
|
|
PointElement,
|
|
LineController,
|
|
BarController,
|
|
LinearScale,
|
|
CategoryScale,
|
|
Legend,
|
|
Title,
|
|
Tooltip,
|
|
Filler,
|
|
ChartType
|
|
} from "chart.js";
|
|
import type { BarOptions, ChartOptions } from "chart.js";
|
|
|
|
import type { Ref } from "vue";
|
|
import { convertSecondsToHumanReadable } from "../utils";
|
|
import { GraphTypes, GraphValueTypes } from "../interfaces/IGraph";
|
|
import type { IGraphDataset, IGraphData } from "../interfaces/IGraph";
|
|
|
|
Chart.register(
|
|
LineElement,
|
|
BarElement,
|
|
PointElement,
|
|
LineController,
|
|
BarController,
|
|
LinearScale,
|
|
CategoryScale,
|
|
Legend,
|
|
Title,
|
|
Tooltip,
|
|
Filler
|
|
);
|
|
|
|
interface Props {
|
|
name?: string;
|
|
data: IGraphData;
|
|
type: ChartType;
|
|
stacked: boolean;
|
|
datasetDescriptionSuffix: string;
|
|
tooltipDescriptionSuffix: string;
|
|
graphValueType?: GraphValueTypes;
|
|
}
|
|
|
|
const props = defineProps<Props>();
|
|
const graphCanvas: Ref<HTMLCanvasElement | null> = ref(null);
|
|
let graphInstance: Chart | null = null;
|
|
|
|
const graphTemplates = [
|
|
{
|
|
borderColor: "#6366F1",
|
|
backgroundColor: "rgba(99,102,241,0.12)"
|
|
},
|
|
{
|
|
borderColor: "#F59E0B",
|
|
backgroundColor: "rgba(245,158,11,0.12)"
|
|
},
|
|
{
|
|
borderColor: "#10B981",
|
|
backgroundColor: "rgba(16,185,129,0.12)"
|
|
}
|
|
];
|
|
|
|
onMounted(() => generateGraph());
|
|
watch(() => props.data, generateGraph, { deep: true });
|
|
|
|
onBeforeUnmount(() => {
|
|
if (graphInstance) graphInstance.destroy();
|
|
});
|
|
|
|
function removeEmptyDataset(dataset: IGraphDataset) {
|
|
return dataset;
|
|
return !dataset.data.every(point => point === 0);
|
|
}
|
|
|
|
function hydrateDataset(dataset: IGraphDataset, index: number) {
|
|
const base = graphTemplates[index % graphTemplates.length];
|
|
|
|
if (props.type === "bar") {
|
|
return {
|
|
label: `${dataset.name} ${props.datasetDescriptionSuffix}`,
|
|
data: dataset.data,
|
|
backgroundColor: base.borderColor,
|
|
inflateAmount: 0,
|
|
borderRadius: {
|
|
topLeft: 8,
|
|
topRight: 8,
|
|
bottomLeft: 8,
|
|
bottomRight: 8
|
|
},
|
|
|
|
borderSkipped: false,
|
|
borderWidth: 2,
|
|
borderColor: "transparent",
|
|
|
|
// Slight spacing between categories
|
|
barPercentage: 0.8,
|
|
categoryPercentage: 0.9
|
|
} as BarOptions;
|
|
}
|
|
|
|
// Line chart — subtle, minimal points
|
|
return {
|
|
label: `${dataset.name} ${props.datasetDescriptionSuffix}`,
|
|
data: dataset.data,
|
|
borderColor: base.borderColor,
|
|
backgroundColor: base.backgroundColor,
|
|
borderWidth: 2,
|
|
tension: 0.35,
|
|
fill: true,
|
|
|
|
pointRadius: 2,
|
|
pointHoverRadius: 5,
|
|
pointHitRadius: 12,
|
|
pointBackgroundColor: base.borderColor,
|
|
pointBorderColor: base.borderColor,
|
|
pointBorderWidth: 0
|
|
};
|
|
}
|
|
|
|
function generateGraph() {
|
|
if (!graphCanvas.value) return;
|
|
|
|
const datasets = props.data.series
|
|
.filter(removeEmptyDataset)
|
|
.map(hydrateDataset);
|
|
|
|
const chartData = {
|
|
labels: props.data.labels,
|
|
datasets
|
|
};
|
|
|
|
const options: ChartOptions = {
|
|
maintainAspectRatio: false,
|
|
responsive: true,
|
|
layout: {
|
|
padding: { top: 8 }
|
|
},
|
|
plugins: {
|
|
legend: {
|
|
display: true
|
|
},
|
|
|
|
tooltip: {
|
|
backgroundColor: "#111827",
|
|
bodyColor: "#e5e7eb",
|
|
padding: 12,
|
|
cornerRadius: 8,
|
|
displayColors: true,
|
|
callbacks: {
|
|
label: (tooltipItem: any) => {
|
|
const context = tooltipItem.dataset.label.split(" ")[0];
|
|
|
|
let type = GraphTypes.Plays;
|
|
let value = tooltipItem.raw;
|
|
if (props.graphValueType === String(GraphTypes.Duration)) {
|
|
value = convertSecondsToHumanReadable(value);
|
|
type = GraphTypes.Duration;
|
|
}
|
|
|
|
const text = `${context} ${type}`;
|
|
return `${text}: ${value}`;
|
|
}
|
|
}
|
|
}
|
|
},
|
|
|
|
scales: {
|
|
x: {
|
|
stacked: props.stacked,
|
|
grid: {
|
|
display: false,
|
|
drawBorder: false
|
|
},
|
|
ticks: {
|
|
color: "#9CA3AF",
|
|
font: { size: 11 }
|
|
}
|
|
},
|
|
|
|
y: {
|
|
stacked: props.stacked,
|
|
beginAtZero: true,
|
|
grid: {
|
|
color: "rgba(0,0,0,0.04)",
|
|
drawBorder: false
|
|
},
|
|
ticks: {
|
|
color: "#9CA3AF",
|
|
font: { size: 11 },
|
|
padding: 8,
|
|
callback: (value: number) => {
|
|
if (props.graphValueType === String(GraphTypes.Duration)) {
|
|
return convertSecondsToHumanReadable(value);
|
|
}
|
|
return value;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
if (graphInstance) {
|
|
graphInstance.data = chartData;
|
|
graphInstance.update();
|
|
return;
|
|
}
|
|
|
|
graphInstance = new Chart(graphCanvas.value, {
|
|
type: props.type,
|
|
data: chartData,
|
|
options
|
|
});
|
|
}
|
|
</script>
|
|
|
|
<style scoped lang="scss">
|
|
.graph-wrapper {
|
|
position: relative;
|
|
width: 100%;
|
|
height: 100%;
|
|
min-height: 240px;
|
|
}
|
|
</style>
|