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
This commit is contained in:
2026-03-08 21:38:22 +01:00
committed by GitHub
parent 0cd2a73a8b
commit cb90281e5e
6 changed files with 879 additions and 224 deletions

View File

@@ -1,9 +1,11 @@
<template> <template>
<canvas ref="graphCanvas"></canvas> <div class="graph-wrapper">
<canvas ref="graphCanvas"></canvas>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, watch } from "vue"; import { ref, onMounted, watch, onBeforeUnmount } from "vue";
import { import {
Chart, Chart,
LineElement, LineElement,
@@ -16,12 +18,14 @@
Legend, Legend,
Title, Title,
Tooltip, Tooltip,
Filler,
ChartType ChartType
} from "chart.js"; } from "chart.js";
import type { BarOptions, ChartOptions } from "chart.js";
import type { Ref } from "vue"; import type { Ref } from "vue";
import { convertSecondsToHumanReadable } from "../utils"; import { convertSecondsToHumanReadable } from "../utils";
import { GraphValueTypes } from "../interfaces/IGraph"; import { GraphTypes, GraphValueTypes } from "../interfaces/IGraph";
import type { IGraphDataset, IGraphData } from "../interfaces/IGraph"; import type { IGraphDataset, IGraphData } from "../interfaces/IGraph";
Chart.register( Chart.register(
@@ -34,7 +38,8 @@
CategoryScale, CategoryScale,
Legend, Legend,
Title, Title,
Tooltip Tooltip,
Filler
); );
interface Props { interface Props {
@@ -42,129 +47,188 @@
data: IGraphData; data: IGraphData;
type: ChartType; type: ChartType;
stacked: boolean; stacked: boolean;
datasetDescriptionSuffix: string; datasetDescriptionSuffix: string;
tooltipDescriptionSuffix: string; tooltipDescriptionSuffix: string;
graphValueType?: GraphValueTypes; graphValueType?: GraphValueTypes;
} }
Chart.defaults.elements.point.radius = 0;
Chart.defaults.elements.point.hitRadius = 10;
// Chart.defaults.elements.point.pointHoverRadius = 10;
Chart.defaults.elements.point.hoverBorderWidth = 4;
const props = defineProps<Props>(); const props = defineProps<Props>();
const graphCanvas: Ref<HTMLCanvasElement> = ref(null); const graphCanvas: Ref<HTMLCanvasElement | null> = ref(null);
let graphInstance = null; let graphInstance: Chart | null = null;
/* eslint-disable no-use-before-define */
onMounted(() => generateGraph());
watch(() => props.data, generateGraph);
/* eslint-enable no-use-before-define */
const graphTemplates = [ const graphTemplates = [
{ {
backgroundColor: "rgba(54, 162, 235, 0.2)", borderColor: "#6366F1",
borderColor: "rgba(54, 162, 235, 1)", backgroundColor: "rgba(99,102,241,0.12)"
borderWidth: 1,
tension: 0.4
}, },
{ {
backgroundColor: "rgba(255, 159, 64, 0.2)", borderColor: "#F59E0B",
borderColor: "rgba(255, 159, 64, 1)", backgroundColor: "rgba(245,158,11,0.12)"
borderWidth: 1,
tension: 0.4
}, },
{ {
backgroundColor: "rgba(255, 99, 132, 0.2)", borderColor: "#10B981",
borderColor: "rgba(255, 99, 132, 1)", backgroundColor: "rgba(16,185,129,0.12)"
borderWidth: 1,
tension: 0.4
} }
]; ];
// const gridColor = getComputedStyle(document.documentElement).getPropertyValue(
// "--text-color-5"
// );
function hydrateGraphLineOptions(dataset: IGraphDataset, index: number) { 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 { return {
label: `${dataset.name} ${props.datasetDescriptionSuffix}`, label: `${dataset.name} ${props.datasetDescriptionSuffix}`,
data: dataset.data, data: dataset.data,
...graphTemplates[index] 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 removeEmptyDataset(dataset: IGraphDataset) {
/* eslint-disable-next-line no-unneeded-ternary */
return dataset.data.every(point => point === 0) ? false : true;
}
function generateGraph() { function generateGraph() {
if (!graphCanvas.value) return;
const datasets = props.data.series const datasets = props.data.series
.filter(removeEmptyDataset) .filter(removeEmptyDataset)
.map(hydrateGraphLineOptions); .map(hydrateDataset);
const graphOptions = { const chartData = {
labels: props.data.labels,
datasets
};
const options: ChartOptions = {
maintainAspectRatio: false, maintainAspectRatio: false,
responsive: true,
layout: {
padding: { top: 8 }
},
plugins: { plugins: {
tooltip: { legend: {
callbacks: { display: true
// title: (tooltipItem, data) => `Watch date: ${tooltipItem[0].label}`, },
label: tooltipItem => {
const context = tooltipItem.dataset.label.split(" ")[0];
const text = `${context} ${props.tooltipDescriptionSuffix}`;
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; let value = tooltipItem.raw;
if (props.graphValueType === GraphValueTypes.Time) { if (props.graphValueType === String(GraphTypes.Duration)) {
value = convertSecondsToHumanReadable(value); value = convertSecondsToHumanReadable(value);
type = GraphTypes.Duration;
} }
return ` ${text}: ${value}`; const text = `${context} ${type}`;
return `${text}: ${value}`;
} }
} }
} }
}, },
scales: { scales: {
xAxes: { x: {
stacked: props.stacked, stacked: props.stacked,
gridLines: { grid: {
display: false display: false,
drawBorder: false
},
ticks: {
color: "#9CA3AF",
font: { size: 11 }
} }
}, },
yAxes: {
y: {
stacked: props.stacked, stacked: props.stacked,
beginAtZero: true,
grid: {
color: "rgba(0,0,0,0.04)",
drawBorder: false
},
ticks: { ticks: {
callback: value => { color: "#9CA3AF",
if (props.graphValueType === GraphValueTypes.Time) { font: { size: 11 },
padding: 8,
callback: (value: number) => {
if (props.graphValueType === String(GraphTypes.Duration)) {
return convertSecondsToHumanReadable(value); return convertSecondsToHumanReadable(value);
} }
return value; return value;
}, }
beginAtZero: true
} }
} }
} }
}; };
const chartData = {
labels: props.data.labels.toString().split(","),
datasets
};
if (graphInstance) { if (graphInstance) {
graphInstance.clear();
graphInstance.data = chartData; graphInstance.data = chartData;
graphInstance.update("none"); graphInstance.update();
return; return;
} }
graphInstance = new Chart(graphCanvas.value, { graphInstance = new Chart(graphCanvas.value, {
type: props.type, type: props.type,
data: chartData, data: chartData,
options: graphOptions options
}); });
} }
</script> </script>
<style lang="scss" scoped></style> <style scoped lang="scss">
.graph-wrapper {
position: relative;
width: 100%;
height: 100%;
min-height: 240px;
}
</style>

View File

@@ -0,0 +1,86 @@
<template>
<div v-if="watchStats" class="stats-overview">
<div class="stat-card">
<div class="stat-value">{{ watchStats.totalPlays }}</div>
<div class="stat-label">Total Plays</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ watchStats.totalHours }}h</div>
<div class="stat-label">Watch Time</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ watchStats.moviePlays }}</div>
<div class="stat-label">Movies watched</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ watchStats.episodePlays }}</div>
<div class="stat-label">Episodes watched</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { WatchStats } from "../../composables/useTautulliStats";
interface Props {
watchStats: WatchStats | null;
}
defineProps<Props>();
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.stats-overview {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
@include mobile-only {
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
}
.stat-card {
background: var(--background-ui);
padding: 1.5rem;
border-radius: 12px;
text-align: center;
transition: transform 0.2s;
&:hover {
transform: translateY(-4px);
}
@include mobile-only {
padding: 1rem;
}
}
.stat-value {
font-size: 2.5rem;
font-weight: 700;
color: var(--highlight-color);
margin-bottom: 0.5rem;
@include mobile-only {
font-size: 2rem;
}
}
.stat-label {
font-size: 0.9rem;
color: var(--text-color-60);
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 300;
@include mobile-only {
font-size: 0.8rem;
}
}
</style>

View File

@@ -0,0 +1,101 @@
<template>
<div v-if="topContent.length > 0" class="watch-history">
<h3 class="section-title">Last Watched</h3>
<div class="top-content-list">
<div
v-for="(item, index) in topContent"
:key="index"
class="top-content-item"
>
<div class="content-rank">{{ index + 1 }}</div>
<div class="content-details">
<div class="content-title">{{ item.title }}</div>
<div class="content-meta">
{{ item.type }} {{ item.plays }} plays {{ item.duration }}min
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
interface TopContentItem {
title: string;
type: string;
plays: number;
duration: number;
}
interface Props {
topContent: TopContentItem[];
}
defineProps<Props>();
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.watch-history {
margin-top: 2rem;
}
.section-title {
margin: 0 0 1rem 0;
font-size: 1.2rem;
font-weight: 500;
color: $text-color;
}
.top-content-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
@include mobile-only {
grid-template-columns: 1fr;
}
}
.top-content-item {
display: flex;
align-items: center;
gap: 1rem;
background: var(--background-ui);
padding: 1rem;
border-radius: 8px;
border: 1px solid var(--text-color-50);
transition: all 0.2s;
&:hover {
border-color: var(--text-color);
transform: translateY(-2px);
}
}
.content-rank {
font-size: 1.5rem;
font-weight: 700;
color: var(--highlight-color);
min-width: 2.5rem;
text-align: center;
}
.content-details {
flex: 1;
}
.content-title {
font-size: 1rem;
font-weight: 600;
color: var(--text-color);
margin-bottom: 0.25rem;
}
.content-meta {
font-size: 0.85rem;
color: var(--text-color-60);
}
</style>

View File

@@ -0,0 +1,299 @@
import { API_HOSTNAME } from "../api";
export interface WatchStats {
totalHours: number;
totalPlays: number;
moviePlays: number;
episodePlays: number;
musicPlays: number;
lastWatched: WatchContent[];
}
interface DayStats {
date: string;
plays: number;
duration: number;
}
interface HomeStatItem {
rating_key: number;
title: string;
total_plays?: number;
total_duration?: number;
users_watched?: string;
last_play?: number;
grandparent_thumb?: string;
thumb?: string;
content_rating?: string;
labels?: string[];
media_type?: string;
}
export interface WatchContent {
title: string;
plays: number;
duration: number;
type: string;
}
interface PlaysGraphData {
categories: string[];
series: {
name: string;
data: number[];
}[];
}
export async function tautulliRequest(
resource: string,
params: Record<string, any> = {}
) {
try {
const queryParams = new URLSearchParams(params);
const url = new URL(
`/api/v1/user/stats/${resource}?${queryParams}`,
API_HOSTNAME
);
const options: RequestInit = {
headers: {
"Content-Type": "application/json"
},
credentials: "include"
};
const resp = await fetch(url, options);
if (!resp.ok) {
throw new Error(`Tautulli API request failed: ${resp.statusText}`);
}
const response = await resp.json();
if (response?.success !== true) {
throw new Error(response?.message || "Unknown API error");
}
return response.data;
} catch (error) {
console.error(`[Tautulli] Error with ${resource}:`, error);
throw error;
}
}
// Fetch home statistics (pre-aggregated by Tautulli!)
export async function fetchHomeStats(
timeRange = 30,
statsType: "plays" | "duration" = "plays"
): Promise<WatchStats> {
try {
const params: Record<string, any> = {
days: timeRange,
type: statsType,
grouping: 0
};
const stats = await tautulliRequest("home_stats", params);
// Extract stats from the response
let totalPlays = 0;
let totalHours = 0;
let moviePlays = 0;
let episodePlays = 0;
let musicPlays = 0;
// Find the relevant stat sections
const topMovies = stats.find((s: any) => s.stat_id === "top_movies");
const topTV = stats.find((s: any) => s.stat_id === "top_tv");
const topMusic = stats.find((s: any) => s.stat_id === "top_music");
if (topMovies?.rows) {
moviePlays = topMovies.rows.reduce(
(sum: number, item: any) => sum + (item.total_plays || 0),
0
);
}
if (topTV?.rows) {
episodePlays = topTV.rows.reduce(
(sum: number, item: any) => sum + (item.total_plays || 0),
0
);
}
if (topMusic?.rows) {
musicPlays = topMusic.rows.reduce(
(sum: number, item: any) => sum + (item.total_plays || 0),
0
);
}
totalPlays = moviePlays + episodePlays + musicPlays;
// Calculate total hours from duration
if (statsType === "duration") {
const totalDuration = [topMovies, topTV, topMusic].reduce((sum, stat) => {
if (!stat?.rows) return sum;
return (
sum +
stat.rows.reduce(
(s: number, item: any) => s + (item.total_duration || 0),
0
)
);
}, 0);
totalHours = Math.round(totalDuration / 3600); // Convert seconds to hours
}
// Get "last_watched" stat which contains recent items
const limit = 12;
const lastWatched = stats
.find((s: any) => s.stat_id === "last_watched")
.rows.slice(0, limit)
.map((item: any) => ({
title: item.title || item.full_title || "Unknown",
plays: item.total_plays || 0,
duration: Math.round((item.total_duration || 0) / 60), // Convert to minutes
type: item.media_type || "unknown"
}));
return {
totalHours,
totalPlays,
moviePlays,
episodePlays,
musicPlays,
lastWatched
};
} catch (error) {
console.error("[Tautulli] Error fetching home stats:", error);
return {
totalHours: 0,
totalPlays: 0,
moviePlays: 0,
episodePlays: 0,
musicPlays: 0,
lastWatched: []
};
}
}
// Fetch plays by date (already aggregated by Tautulli!)
export async function fetchPlaysByDate(
timeRange = 30,
yAxis: "plays" | "duration" = "plays"
): Promise<DayStats[]> {
try {
const params: Record<string, any> = {
days: timeRange,
y_axis: yAxis,
grouping: 0
};
const data: PlaysGraphData = await tautulliRequest("plays_by_date", params);
// Sum all series data for each date
return data.categories.map((date, index) => {
const totalValue = data.series
.filter(s => s.name !== "Total")
.reduce((sum, series) => sum + (series.data[index] || 0), 0);
return {
date,
plays: yAxis === "plays" ? totalValue : 0,
duration: yAxis === "duration" ? totalValue : 0
};
});
} catch (error) {
console.error("[Tautulli] Error fetching plays by date:", error);
return [];
}
}
// Fetch plays by day of week (already aggregated!)
export async function fetchPlaysByDayOfWeek(
timeRange = 30,
yAxis: "plays" | "duration" = "plays"
): Promise<{
labels: string[];
movies: number[];
episodes: number[];
music: number[];
}> {
try {
const params: Record<string, any> = {
days: timeRange,
y_axis: yAxis,
grouping: 0
};
const data: PlaysGraphData = await tautulliRequest(
"plays_by_dayofweek",
params
);
// Map series names to our expected format
const movies =
data.series.find(s => s.name === "Movies")?.data || new Array(7).fill(0);
const episodes =
data.series.find(s => s.name === "TV")?.data || new Array(7).fill(0);
const music =
data.series.find(s => s.name === "Music")?.data || new Array(7).fill(0);
return {
labels: data.categories,
movies,
episodes,
music
};
} catch (error) {
console.error("[Tautulli] Error fetching plays by day of week:", error);
return {
labels: [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday"
],
movies: new Array(7).fill(0),
episodes: new Array(7).fill(0),
music: new Array(7).fill(0)
};
}
}
// Fetch plays by hour of day (already aggregated!)
export async function fetchPlaysByHourOfDay(
timeRange = 30,
yAxis: "plays" | "duration" = "plays"
): Promise<{ labels: string[]; data: number[] }> {
try {
const params: Record<string, any> = {
days: timeRange,
y_axis: yAxis,
grouping: 0
};
const data: PlaysGraphData = await tautulliRequest(
"plays_by_hourofday",
params
);
// Sum all series data for each hour
const hourlyData = data.categories.map((hour, index) =>
data.series.reduce((sum, series) => sum + (series.data[index] || 0), 0)
);
return {
labels: data.categories.map(h => `${h}:00`),
data: hourlyData
};
} catch (error) {
console.error("[Tautulli] Error fetching plays by hour:", error);
return {
labels: Array.from({ length: 24 }, (_, i) => `${i}:00`),
data: new Array(24).fill(0)
};
}
}

View File

@@ -1,84 +1,110 @@
<template> <template>
<div v-if="plexUserId" class="wrapper"> <div class="activity">
<h1>Your watch activity</h1> <h1 class="activity__title">Watch Activity</h1>
<div style="display: flex; flex-direction: row"> <!-- Stats Overview -->
<label class="filter" for="dayinput"> <stats-overview :watch-stats="watchStats" />
<span>Days:</span>
<input
id="dayinput"
v-model="days"
class="dayinput"
placeholder="days"
type="number"
pattern="[0-9]*"
@change="fetchChartData"
/>
</label>
<div class="filter"> <div class="controls">
<span>Data sorted by:</span> <div class="control-group">
<label class="control-label">Time Range</label>
<div class="input-wrapper">
<input
v-model.number="days"
class="days-input"
type="number"
min="1"
max="365"
@change="fetchChartData"
/>
<span class="input-suffix">days</span>
</div>
</div>
<div class="control-group">
<label class="control-label">View Mode</label>
<toggle-button <toggle-button
v-model:selected="graphViewMode" v-model:selected="graphViewMode"
class="filter-item"
:options="[GraphTypes.Plays, GraphTypes.Duration]" :options="[GraphTypes.Plays, GraphTypes.Duration]"
@change="fetchChartData" @change="fetchChartData"
/> />
</div> </div>
</div> </div>
<div class="chart-section"> <div class="activity__charts">
<h3 class="chart-header">Activity per day:</h3> <div class="chart-card">
<div class="graph"> <h3>Daily Activity</h3>
<Graph <div class="chart-card__graph">
v-if="playsByDayData" <Graph
:data="playsByDayData" v-if="playsByDayData"
type="line" :data="playsByDayData"
:stacked="false" type="line"
:dataset-description-suffix="`watch last ${days} days`" :stacked="false"
:tooltip-description-suffix="selectedGraphViewMode.tooltipLabel" :dataset-description-suffix="`watch last ${days} days`"
:graph-value-type="selectedGraphViewMode.valueType" :tooltip-description-suffix="selectedGraphViewMode.tooltipLabel"
/> :graph-value-type="graphViewMode"
/>
</div>
</div> </div>
<h3 class="chart-header">Activity per day of week:</h3> <div class="chart-card">
<div class="graph"> <h3>Activity by Media Type</h3>
<Graph <div class="chart-card__graph">
v-if="playsByDayofweekData" <Graph
:data="playsByDayofweekData" v-if="playsByDayofweekData"
type="bar" :data="playsByDayofweekData"
:stacked="true" :graphValueType="graphViewMode"
:dataset-description-suffix="`watch last ${days} days`" type="bar"
:tooltip-description-suffix="selectedGraphViewMode.tooltipLabel" :stacked="true"
:graph-value-type="selectedGraphViewMode.valueType" :dataset-description-suffix="`watch last ${days} days`"
/> tooltip-description-suffix="plays"
/>
</div>
</div>
<div class="chart-card">
<h3>Viewing Patterns by Hour</h3>
<div class="chart-card__graph">
<Graph
v-if="hourlyData"
:data="hourlyData"
type="bar"
:stacked="false"
:dataset-description-suffix="`last ${days} days`"
tooltip-description-suffix="plays"
:graph-value-type="graphViewMode"
/>
</div>
</div> </div>
</div> </div>
</div>
<div v-else class="not-authenticated"> <!-- Top Content -->
<h1><IconStop /> Must be authenticated</h1> <watch-history :top-content="topContent" />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from "vue"; import { ref, computed, onMounted } from "vue";
import { useStore } from "vuex";
import Graph from "@/components/Graph.vue"; import Graph from "@/components/Graph.vue";
import ToggleButton from "@/components/ui/ToggleButton.vue"; import ToggleButton from "@/components/ui/ToggleButton.vue";
import IconStop from "@/icons/IconStop.vue"; import StatsOverview from "@/components/activity/StatsOverview.vue";
import type { Ref } from "vue"; import WatchHistory from "@/components/activity/WatchHistory.vue";
import { fetchGraphData } from "../api"; import {
fetchHomeStats,
fetchPlaysByDate,
fetchPlaysByDayOfWeek,
fetchPlaysByHourOfDay
} from "../composables/useTautulliStats";
import { import {
GraphTypes, GraphTypes,
GraphValueTypes, GraphValueTypes,
IGraphData IGraphData
} from "../interfaces/IGraph"; } from "../interfaces/IGraph";
import type { Ref } from "vue";
const store = useStore(); import type { WatchStats } from "../composables/useTautulliStats";
const days: Ref<number> = ref(30); const days: Ref<number> = ref(30);
const graphViewMode: Ref<GraphTypes> = ref(GraphTypes.Plays); const graphViewMode: Ref<GraphTypes> = ref(GraphTypes.Plays);
const plexUserId = computed(() => store.getters["user/plexUserId"]);
const graphValueViewMode = [ const graphValueViewMode = [
{ {
@@ -95,156 +121,226 @@
const playsByDayData: Ref<IGraphData> = ref(null); const playsByDayData: Ref<IGraphData> = ref(null);
const playsByDayofweekData: Ref<IGraphData> = ref(null); const playsByDayofweekData: Ref<IGraphData> = ref(null);
const hourlyData: Ref<IGraphData> = ref(null);
const watchStats = ref(null);
const topContent = ref([]);
const selectedGraphViewMode = computed(() => const selectedGraphViewMode = computed(() =>
graphValueViewMode.find(viewMode => viewMode.type === graphViewMode.value) graphValueViewMode.find(viewMode => viewMode.type === graphViewMode.value)
); );
function convertDateStringToDayMonth(date: string): string { function convertDateStringToDayMonth(date: string, short = true): string {
if (!date.match(/[0-9]{4}-[0-9]{2}-[0-9]{2}/)) { if (!date.match(/[0-9]{4}-[0-9]{2}-[0-9]{2}/)) {
return date; return date;
} }
const [, month, day] = date.split("-"); const [year, month, day] = date.split("-");
return `${day}.${month}`; return short ? `${month}.${day}` : `${day}.${month}.${year}`;
} }
function convertDateLabels(data) { function activityPerDay(dataPromise: Promise<any>) {
return { dataPromise.then(dayData => {
labels: data.categories.map(convertDateStringToDayMonth), playsByDayData.value = {
series: data.series labels: dayData.map(d =>
}; convertDateStringToDayMonth(d.date, dayData.length < 365)
),
series: [
{
name: "Activity",
data:
graphViewMode.value === GraphTypes.Plays
? dayData.map(d => d.plays)
: dayData.map(d => d.duration)
}
]
};
});
} }
async function fetchPlaysByDay() { function playsByDayOfWeek(dataPromise: Promise<any>) {
playsByDayData.value = await fetchGraphData( dataPromise.then(weekData => {
"plays_by_day", playsByDayofweekData.value = {
days.value, labels: weekData.labels,
graphViewMode.value series: [
).then(data => convertDateLabels(data?.data)); { name: "Movies", data: weekData.movies },
{ name: "Episodes", data: weekData.episodes },
{ name: "Music", data: weekData.music }
]
};
});
} }
async function fetchPlaysByDayOfWeek() { function hourly(hourlyPromise: Promise<any>) {
playsByDayofweekData.value = await fetchGraphData( hourlyPromise.then(hourData => {
"plays_by_dayofweek", hourlyData.value = {
days.value, labels: hourData.labels,
graphViewMode.value series: [{ name: "Plays", data: hourData.data }]
).then(data => convertDateLabels(data?.data)); };
});
} }
function fetchChartData() { async function fetchChartData() {
fetchPlaysByDay(); try {
fetchPlaysByDayOfWeek(); const yAxis =
graphViewMode.value === GraphTypes.Plays ? "plays" : "duration";
// Fetch all data in parallel using efficient Tautulli APIs
fetchHomeStats(days.value, "duration").then(
(homeStats: WatchStats) => (watchStats.value = homeStats)
);
// Activity per day (line graph of last n days)
activityPerDay(fetchPlaysByDate(days.value, yAxis));
// Activity by day of week (stacked by media type)
playsByDayOfWeek(fetchPlaysByDayOfWeek(days.value, yAxis));
// Hourly distribution
hourly(fetchPlaysByHourOfDay(days.value, yAxis));
} catch (error) {
console.error("[ActivityPage] Error fetching chart data:", error);
}
} }
fetchChartData(); onMounted(fetchChartData);
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@import "scss/variables"; @import "scss/variables";
@import "scss/media-queries";
.wrapper { .activity {
padding: 2rem; padding: 3rem;
max-width: 100%;
@include mobile-only { @include mobile-only {
padding: 0 0.8rem; padding: 0.75rem;
}
}
.filter {
margin-top: 0.5rem;
display: inline-flex;
flex-direction: column;
font-size: 1.2rem;
&:not(:first-of-type) {
margin-left: 1.25rem;
} }
input { &__title {
width: 100%; margin: 0 0 2rem 0;
font-size: inherit; font-size: 2rem;
max-width: 6rem;
background-color: $background-ui;
color: $text-color;
}
span {
font-size: inherit;
line-height: 1;
margin: 0.5rem 0;
font-weight: 300; font-weight: 300;
color: $text-color;
line-height: 1;
@include mobile-only {
font-size: 1.5rem;
margin: 1rem 0;
}
}
&__charts {
display: flex;
flex-direction: column;
gap: 1.5rem;
@include mobile-only {
gap: 1rem;
}
} }
} }
// .filter { .chart-card {
// display: flex; background: var(--background-ui);
// flex-direction: row; border-radius: 12px;
// flex-wrap: wrap; padding: 1.5rem;
// align-items: center; border: 1px solid var(--text-color-50);
// margin-bottom: 2rem;
// h2 { @include mobile-only {
// margin-bottom: 0.5rem; padding: 1rem;
// width: 100%; }
// font-weight: 400;
// }
// &-item:not(:first-of-type) { h3 {
// margin-left: 1rem; margin: 0 0 1rem 0;
// } font-size: 1.2rem;
font-weight: 500;
color: $text-color;
// .dayinput { @include mobile-only {
// font-size: 1.2rem; font-size: 1rem;
// max-width: 3rem; }
// background-color: $background-ui; }
// color: $text-color;
// }
// }
.chart-section { &__graph {
display: flex;
flex-wrap: wrap;
.graph {
position: relative; position: relative;
height: 35vh; height: 35vh;
width: 90vw; min-height: 300px;
margin-bottom: 2rem;
}
.chart-header { @include mobile-only {
font-weight: 300; height: 30vh;
min-height: 250px;
}
} }
} }
.not-authenticated { .controls {
padding: 2rem; display: flex;
gap: 2rem;
margin-bottom: 2rem;
flex-wrap: wrap;
h1 { @include mobile-only {
display: flex; flex-direction: column;
align-items: center; gap: 1rem;
font-size: 3rem;
svg {
margin-right: 1rem;
height: 3rem;
width: 3rem;
}
} }
@include mobile { }
padding: 1rem;
padding-right: 0;
h1 { .control-group {
font-size: 1.65rem; display: flex;
flex-direction: column;
gap: 0.5rem;
min-width: 200px;
svg { @include mobile-only {
margin-right: 1rem; min-width: 0;
height: 2rem; width: 100%;
width: 2rem;
}
}
} }
} }
.control-label {
font-size: 0.9rem;
font-weight: 500;
color: var(--text-color-60);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.input-wrapper {
display: flex;
align-items: center;
background: var(--background-ui);
border: 1px solid var(--text-color-50);
border-radius: 8px;
overflow: hidden;
transition: border-color 0.2s;
&:hover,
&:focus-within {
border-color: var(--text-color);
}
}
.days-input {
flex: 1;
background: transparent;
border: none;
padding: 0.75rem 1rem;
font-size: 1rem;
color: $text-color;
outline: none;
width: 80px;
&::-webkit-inner-spin-button,
&::-webkit-outer-spin-button {
opacity: 1;
}
}
.input-suffix {
padding: 0 1rem;
font-size: 0.9rem;
color: var(--text-color-60);
user-select: none;
}
</style> </style>

View File

@@ -3,6 +3,8 @@ import type { RouteRecordRaw, RouteLocationNormalized } from "vue-router";
/* eslint-disable-next-line import-x/no-cycle */ /* eslint-disable-next-line import-x/no-cycle */
import store from "./store"; import store from "./store";
import { usePlexAuth } from "./composables/usePlexAuth";
const { getPlexAuthCookie } = usePlexAuth();
declare global { declare global {
interface Window { interface Window {
@@ -96,7 +98,14 @@ const router = createRouter({
}); });
const loggedIn = () => store.getters["user/loggedIn"]; const loggedIn = () => store.getters["user/loggedIn"];
const hasPlexAccount = () => store.getters["user/plexUserId"] !== null; const hasPlexAccount = () => {
// Check Vuex store first
if (store.getters["user/plexUserId"] !== null) return true;
// Fallback to localStorage/cookie for page refreshes
const authToken = getPlexAuthCookie();
return !!authToken;
};
const hamburgerIsOpen = () => store.getters["hamburger/isOpen"]; const hamburgerIsOpen = () => store.getters["hamburger/isOpen"];
router.beforeEach( router.beforeEach(