Refactor ActivityPage with extracted stats and history components

- Extract StatsOverview component for watch statistics display
- Extract WatchHistory component for recently watched content
- Reduce ActivityPage from 490 to 346 lines (~29% reduction)
- Move stats card styles to StatsOverview component
- Move watch history list styles to WatchHistory component
- Clean up useTautulliStats composable
- Improve Graph component for better chart rendering
- Maintain three clear sections: stats, controls+graphs, history
- Follow component extraction pattern from settings refactor
This commit is contained in:
2026-03-08 20:57:20 +01:00
parent 990dde4d31
commit 1b99399b4c
5 changed files with 473 additions and 604 deletions

View File

@@ -56,12 +56,6 @@
const graphCanvas: Ref<HTMLCanvasElement | null> = ref(null);
let graphInstance: Chart | null = null;
/*
|--------------------------------------------------------------------------
| Modern Color System
|--------------------------------------------------------------------------
*/
const graphTemplates = [
{
borderColor: "#6366F1",
@@ -77,12 +71,6 @@
}
];
/*
|--------------------------------------------------------------------------
| Lifecycle
|--------------------------------------------------------------------------
*/
onMounted(() => generateGraph());
watch(() => props.data, generateGraph, { deep: true });
@@ -90,12 +78,6 @@
if (graphInstance) graphInstance.destroy();
});
/*
|--------------------------------------------------------------------------
| Helpers
|--------------------------------------------------------------------------
*/
function removeEmptyDataset(dataset: IGraphDataset) {
return dataset;
return !dataset.data.every(point => point === 0);
@@ -146,12 +128,6 @@
};
}
/*
|--------------------------------------------------------------------------
| Chart Generator
|--------------------------------------------------------------------------
*/
function generateGraph() {
if (!graphCanvas.value) return;

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

@@ -1,12 +1,12 @@
const TAUTULLI_API_KEY = "28494032b47542278fe76c6ccd1f0619";
const TAUTULLI_BASE_URL = "http://plex.schleppe:8181/api/v2";
import { API_HOSTNAME } from "../api";
interface WatchStats {
export interface WatchStats {
totalHours: number;
totalPlays: number;
moviePlays: number;
episodePlays: number;
musicPlays: number;
lastWatched: WatchContent[];
}
interface DayStats {
@@ -29,6 +29,13 @@ interface HomeStatItem {
media_type?: string;
}
export interface WatchContent {
title: string;
plays: number;
duration: number;
type: string;
}
interface PlaysGraphData {
categories: string[];
series: {
@@ -37,310 +44,256 @@ interface PlaysGraphData {
}[];
}
export function useTautulliStats() {
// Helper function to make Tautulli API calls
async function tautulliRequest(
cmd: string,
params: Record<string, any> = {}
) {
try {
const queryParams = new URLSearchParams({
apikey: TAUTULLI_API_KEY,
cmd,
...params
});
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 url = `${TAUTULLI_BASE_URL}?${queryParams}`;
const response = await fetch(url);
const resp = await fetch(url, options);
if (!response.ok) {
throw new Error(`Tautulli API request failed: ${response.statusText}`);
}
const data = await response.json();
if (data.response?.result !== "success") {
throw new Error(data.response?.message || "Unknown API error");
}
return data.response.data;
} catch (error) {
console.error(`[Tautulli] Error with ${cmd}:`, error);
throw error;
if (!resp.ok) {
throw new Error(`Tautulli API request failed: ${resp.statusText}`);
}
}
// Fetch home statistics (pre-aggregated by Tautulli!)
async function fetchHomeStats(
userId?: number,
timeRange = 30,
statsType: "plays" | "duration" = "plays"
): Promise<WatchStats> {
try {
const params: Record<string, any> = {
time_range: timeRange,
stats_type: statsType,
grouping: 0
};
if (userId) {
params.user_id = userId;
}
const stats = await tautulliRequest("get_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
}
return {
totalHours,
totalPlays,
moviePlays,
episodePlays,
musicPlays
};
} catch (error) {
console.error("[Tautulli] Error fetching home stats:", error);
return {
totalHours: 0,
totalPlays: 0,
moviePlays: 0,
episodePlays: 0,
musicPlays: 0
};
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 plays by date (already aggregated by Tautulli!)
async function fetchPlaysByDate(
timeRange = 30,
yAxis: "plays" | "duration" = "plays",
userId?: number
): Promise<DayStats[]> {
try {
const params: Record<string, any> = {
time_range: timeRange,
y_axis: yAxis,
grouping: 0
};
// 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
};
if (userId) {
params.user_id = userId;
}
const stats = await tautulliRequest("home_stats", params);
const data: PlaysGraphData = await tautulliRequest(
"get_plays_by_date",
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
);
// 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!)
async function fetchPlaysByDayOfWeek(
timeRange = 30,
yAxis: "plays" | "duration" = "plays",
userId?: number
): Promise<{
labels: string[];
movies: number[];
episodes: number[];
music: number[];
}> {
try {
const params: Record<string, any> = {
time_range: timeRange,
y_axis: yAxis,
grouping: 0
};
if (userId) {
params.user_id = userId;
}
const data: PlaysGraphData = await tautulliRequest(
"get_plays_by_dayofweek",
params
if (topTV?.rows) {
episodePlays = topTV.rows.reduce(
(sum: number, item: any) => sum + (item.total_plays || 0),
0
);
// 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!)
async function fetchPlaysByHourOfDay(
timeRange = 30,
yAxis: "plays" | "duration" = "plays",
userId?: number
): Promise<{ labels: string[]; data: number[] }> {
try {
const params: Record<string, any> = {
time_range: timeRange,
y_axis: yAxis,
grouping: 0
};
if (userId) {
params.user_id = userId;
}
const data: PlaysGraphData = await tautulliRequest(
"get_plays_by_hourofday",
params
if (topMusic?.rows) {
musicPlays = topMusic.rows.reduce(
(sum: number, item: any) => sum + (item.total_plays || 0),
0
);
// 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)
};
}
}
// Fetch top watched content from home stats
async function fetchTopContent(timeRange = 30, limit = 10, userId?: number) {
try {
const params: Record<string, any> = {
time_range: timeRange,
stats_type: "plays",
stats_count: limit,
grouping: 0
};
totalPlays = moviePlays + episodePlays + musicPlays;
if (userId) {
params.user_id = userId;
}
// 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
}
const stats = await tautulliRequest("get_home_stats", params);
// Get "last_watched" stat which contains recent items
const lastWatched = stats.find((s: any) => s.stat_id === "last_watched");
if (!lastWatched?.rows) {
return [];
}
return lastWatched.rows.slice(0, limit).map((item: any) => ({
// 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"
}));
} catch (error) {
console.error("[Tautulli] Error fetching top content:", error);
return [];
}
}
return {
fetchHomeStats,
fetchPlaysByDate,
fetchPlaysByDayOfWeek,
fetchPlaysByHourOfDay,
fetchTopContent
};
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,26 +1,9 @@
<template>
<div v-if="plexUserId && plexUsername" class="activity">
<div class="activity">
<h1 class="activity__title">Watch Activity</h1>
<!-- Stats Overview -->
<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</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ watchStats.episodePlays }}</div>
<div class="stat-label">Episodes</div>
</div>
</div>
<stats-overview :watch-stats="watchStats" />
<div class="controls">
<div class="control-group">
@@ -50,7 +33,7 @@
<div class="activity__charts">
<div class="chart-card">
<h3 class="chart-card__title">Daily Activity</h3>
<h3>Daily Activity</h3>
<div class="chart-card__graph">
<Graph
v-if="playsByDayData"
@@ -65,7 +48,7 @@
</div>
<div class="chart-card">
<h3 class="chart-card__title">Activity by Media Type</h3>
<h3>Activity by Media Type</h3>
<div class="chart-card__graph">
<Graph
v-if="playsByDayofweekData"
@@ -80,7 +63,7 @@
</div>
<div class="chart-card">
<h3 class="chart-card__title">Viewing Patterns by Hour</h3>
<h3>Viewing Patterns by Hour</h3>
<div class="chart-card__graph">
<Graph
v-if="hourlyData"
@@ -96,80 +79,33 @@
</div>
<!-- Top Content -->
<div v-if="topContent.length > 0" class="activity__top-content">
<h3 class="section-title">Most 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>
</div>
<div v-else class="not-authenticated">
<h1><IconStop /> Must be authenticated with Plex</h1>
<p>Go to Settings to link your Plex account</p>
<watch-history :top-content="topContent" />
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { useStore } from "vuex";
import Graph from "@/components/Graph.vue";
import ToggleButton from "@/components/ui/ToggleButton.vue";
import IconStop from "@/icons/IconStop.vue";
import type { Ref } from "vue";
import { useTautulliStats } from "@/composables/useTautulliStats";
import StatsOverview from "@/components/activity/StatsOverview.vue";
import WatchHistory from "@/components/activity/WatchHistory.vue";
import {
fetchHomeStats,
fetchPlaysByDate,
fetchPlaysByDayOfWeek,
fetchPlaysByHourOfDay
} from "../composables/useTautulliStats";
import {
GraphTypes,
GraphValueTypes,
IGraphData
} from "../interfaces/IGraph";
const store = useStore();
import type { Ref } from "vue";
import type { WatchStats } from "../composables/useTautulliStats";
const days: Ref<number> = ref(30);
const graphViewMode: Ref<GraphTypes> = ref(GraphTypes.Plays);
// Check both Vuex store and localStorage for Plex user
const plexUserId = computed(() => {
// First try Vuex store
const storeId = store.getters["user/plexUserId"];
if (storeId) return storeId;
// Fallback to localStorage
const userData = localStorage.getItem("plex_user_data");
if (userData) {
try {
return JSON.parse(userData).id;
} catch {
return null;
}
}
return null;
});
const plexUsername = computed(() => {
const userData = localStorage.getItem("plex_user_data");
if (userData) {
try {
return JSON.parse(userData).username;
} catch {
return null;
}
}
return null;
});
const graphValueViewMode = [
{
type: GraphTypes.Plays,
@@ -193,14 +129,6 @@
graphValueViewMode.find(viewMode => viewMode.type === graphViewMode.value)
);
const {
fetchHomeStats,
fetchPlaysByDate,
fetchPlaysByDayOfWeek,
fetchPlaysByHourOfDay,
fetchTopContent
} = useTautulliStats();
function convertDateStringToDayMonth(date: string, short = true): string {
if (!date.match(/[0-9]{4}-[0-9]{2}-[0-9]{2}/)) {
return date;
@@ -210,30 +138,8 @@
return short ? `${month}.${day}` : `${day}.${month}.${year}`;
}
async function fetchChartData() {
if (!plexUserId.value) return;
try {
const yAxis =
graphViewMode.value === GraphTypes.Plays ? "plays" : "duration";
// Fetch all data in parallel using efficient Tautulli APIs
const [homeStats, dayData, weekData, hourData, topContentData] =
await Promise.all([
fetchHomeStats(plexUserId.value, days.value, "duration"), // Need duration for hours
fetchPlaysByDate(days.value, yAxis, plexUserId.value),
fetchPlaysByDayOfWeek(days.value, yAxis, plexUserId.value),
fetchPlaysByHourOfDay(days.value, yAxis, plexUserId.value),
fetchTopContent(days.value, 10, plexUserId.value)
]);
// Set overall stats
watchStats.value = homeStats;
// Set top content
topContent.value = topContentData;
// Activity per day
function activityPerDay(dataPromise: Promise<any>) {
dataPromise.then(dayData => {
playsByDayData.value = {
labels: dayData.map(d =>
convertDateStringToDayMonth(d.date, dayData.length < 365)
@@ -248,8 +154,11 @@
}
]
};
});
}
// Activity by day of week (stacked by media type)
function playsByDayOfWeek(dataPromise: Promise<any>) {
dataPromise.then(weekData => {
playsByDayofweekData.value = {
labels: weekData.labels,
series: [
@@ -258,22 +167,42 @@
{ name: "Music", data: weekData.music }
]
};
});
}
// Hourly distribution
function hourly(hourlyPromise: Promise<any>) {
hourlyPromise.then(hourData => {
hourlyData.value = {
labels: hourData.labels,
series: [{ name: "Plays", data: hourData.data }]
};
});
}
async function fetchChartData() {
try {
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);
}
}
onMounted(() => {
if (plexUsername.value) {
fetchChartData();
}
});
onMounted(fetchChartData);
</script>
<style lang="scss" scoped>
@@ -297,7 +226,7 @@
@include mobile-only {
font-size: 1.5rem;
margin: 0 0 1rem 0;
margin: 1rem 0;
}
}
@@ -310,37 +239,8 @@
gap: 1rem;
}
}
&__top-content {
margin-top: 2rem;
}
}
// .filter {
// display: flex;
// flex-direction: row;
// flex-wrap: wrap;
// align-items: center;
// margin-bottom: 2rem;
// h2 {
// margin-bottom: 0.5rem;
// width: 100%;
// font-weight: 400;
// }
// &-item:not(:first-of-type) {
// margin-left: 1rem;
// }
// .dayinput {
// font-size: 1.2rem;
// max-width: 3rem;
// background-color: $background-ui;
// color: $text-color;
// }
// }
.chart-card {
background: var(--background-ui);
border-radius: 12px;
@@ -351,7 +251,7 @@
padding: 1rem;
}
&__title {
h3 {
margin: 0 0 1rem 0;
font-size: 1.2rem;
font-weight: 500;
@@ -374,13 +274,6 @@
}
}
.section-title {
margin: 0 0 1rem 0;
font-size: 1.2rem;
font-weight: 500;
color: $text-color;
}
.controls {
display: flex;
gap: 2rem;
@@ -450,144 +343,4 @@
color: var(--text-color-60);
user-select: none;
}
.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;
}
}
.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);
}
.not-authenticated {
padding: 2rem;
text-align: center;
h1 {
display: flex;
align-items: center;
justify-content: center;
font-size: 3rem;
margin-bottom: 1rem;
svg {
margin-right: 1rem;
height: 3rem;
width: 3rem;
}
}
p {
font-size: 1.2rem;
color: var(--text-color-60);
}
@include mobile {
padding: 1rem;
padding-right: 0;
h1 {
font-size: 1.65rem;
svg {
margin-right: 1rem;
height: 2rem;
width: 2rem;
}
}
}
}
</style>