Files
seasoned/src/components/Graph.vue
Kevin cb90281e5e Feat: Activity page enhancements (#106)
* 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
2026-03-08 21:38:22 +01:00

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>