From 5f5d24cb2d837d669067a78c2ab48443f82af990 Mon Sep 17 00:00:00 2001 From: SirBlob Date: Sun, 1 Nov 2026 21:50:05 -0500 Subject: [PATCH] Add hobby stats and graphs to btop --- src/lib/btop/hobbies.ts | 102 ++++++++++++++++++ src/lib/btop/types.ts | 47 ++++++++ src/lib/components/btop/BtopBox.svelte | 5 +- src/lib/components/btop/CpuPanel.svelte | 3 +- src/lib/components/btop/HobbyPanels.svelte | 50 +++++++++ src/lib/components/btop/SystemMonitor.svelte | 34 ++++-- .../components/btop/charts/BarGraph.svelte | 53 +++++++++ .../components/btop/charts/CountBars.svelte | 46 ++++++++ .../btop/charts/SectionLabel.svelte | 16 +++ .../components/btop/charts/StackedBar.svelte | 40 +++++++ .../components/btop/charts/StatRows.svelte | 26 +++++ .../btop/hobbies/AniListPanel.svelte | 18 ++++ .../components/btop/hobbies/BooksPanel.svelte | 37 +++++++ .../btop/hobbies/ListeningPanel.svelte | 27 +++++ .../btop/hobbies/ScreenPanel.svelte | 49 +++++++++ src/lib/server/btopHobbies.ts | 24 +++++ src/routes/api/btop/hobbies/+server.ts | 7 ++ 17 files changed, 571 insertions(+), 13 deletions(-) create mode 100644 src/lib/btop/hobbies.ts create mode 100644 src/lib/components/btop/HobbyPanels.svelte create mode 100644 src/lib/components/btop/charts/BarGraph.svelte create mode 100644 src/lib/components/btop/charts/CountBars.svelte create mode 100644 src/lib/components/btop/charts/SectionLabel.svelte create mode 100644 src/lib/components/btop/charts/StackedBar.svelte create mode 100644 src/lib/components/btop/charts/StatRows.svelte create mode 100644 src/lib/components/btop/hobbies/AniListPanel.svelte create mode 100644 src/lib/components/btop/hobbies/BooksPanel.svelte create mode 100644 src/lib/components/btop/hobbies/ListeningPanel.svelte create mode 100644 src/lib/components/btop/hobbies/ScreenPanel.svelte create mode 100644 src/lib/server/btopHobbies.ts create mode 100644 src/routes/api/btop/hobbies/+server.ts diff --git a/src/lib/btop/hobbies.ts b/src/lib/btop/hobbies.ts new file mode 100644 index 0000000..03f176b --- /dev/null +++ b/src/lib/btop/hobbies.ts @@ -0,0 +1,102 @@ +import type { Book, RatedTitle } from '#lib/content'; +import type { AniListProfile } from '#lib/anilist/types'; +import type { ListeningProfile } from '#lib/lastfm/types'; +import type { AniListStats, BookStats, CountStat, ListeningStats, ScreenStats } from './types'; + +const recentDays = 30; +const topLimit = 5; +const statusColors: Record = { + CURRENT: 'blue', + REPEATING: 'cyan', + COMPLETED: 'green', + PAUSED: 'yellow', + DROPPED: 'red', + PLANNING: 'magenta' +}; + +function average(values: number[]): string { + if (!values.length) return '-'; + return `${(values.reduce((total, value) => total + value, 0) / values.length).toFixed(1)}/10`; +} + +function ratingCounts(ratings: number[]): number[] { + const counts = Array(11).fill(0); + ratings.forEach((rating) => counts[Math.round(rating)]++); + return counts; +} + +function topCounts(values: string[], color: string): CountStat[] { + const counts = new Map(); + values.forEach((value) => counts.set(value, (counts.get(value) ?? 0) + 1)); + return [...counts.entries()] + .sort((first, second) => second[1] - first[1]) + .slice(0, topLimit) + .map(([label, count]) => ({ label, count, color })); +} + +export function bookStats(books: Book[]): BookStats | null { + if (!books.length) return null; + const read = books.filter((book) => book.shelf === 'read'); + const ratings = read.flatMap((book) => (book.rating === undefined ? [] : [book.rating])); + const seriesNames = new Set(books.flatMap((book) => (book.series ? [book.series.name] : []))); + const pagesRead = read.reduce((total, book) => total + (book.pages ?? 0), 0); + + return { + shelves: [ + { label: 'read', count: read.length, color: 'green' }, + { label: 'reading', count: books.filter((book) => book.shelf === 'reading').length, color: 'blue' }, + { label: 'want to read', count: books.filter((book) => book.shelf === 'want-to-read').length, color: 'magenta' } + ], + rows: [ + { label: 'Pages read', value: pagesRead.toLocaleString('en-US') }, + { label: 'Authors', value: `${new Set(read.flatMap((book) => book.authors)).size}` }, + { label: 'Series', value: `${seriesNames.size}` }, + { label: 'Avg rating', value: average(ratings) } + ], + topGenres: topCounts(read.flatMap((book) => book.genres), 'cyan'), + ratingCounts: ratingCounts(ratings) + }; +} + +export function screenStats(titles: RatedTitle[]): ScreenStats { + const ratings = titles.flatMap((title) => (title.rating === undefined ? [] : [title.rating])); + const watching = titles.filter((title) => title.status === 'watching').map((title) => title.name); + const groups = new Set(titles.flatMap((title) => (title.collection ? [title.collection] : []))); + + return { + rows: [ + { label: 'Titles', value: `${titles.length}` }, + { label: 'Watching', value: `${watching.length}` }, + { label: 'Groups', value: `${groups.size}` }, + { label: 'Avg rating', value: average(ratings) } + ], + ratingCounts: ratingCounts(ratings), + watching + }; +} + +export function listeningStats(profile: ListeningProfile): ListeningStats { + const artists = profile.top.month.artists.length ? profile.top.month.artists : profile.top.all.artists; + return { + profileUrl: profile.profileUrl, + rows: [ + { label: 'Scrobbles', value: profile.totals.scrobbles.toLocaleString('en-US') }, + { label: 'Artists', value: profile.totals.artists.toLocaleString('en-US') }, + { label: 'Per day', value: `${profile.averagePerDay}` }, + { label: 'Streak', value: `${profile.currentStreak}d (best ${profile.longestStreak}d)` } + ], + daily: profile.daily.slice(-recentDays).map((bucket) => bucket.count), + hourly: profile.hourly.map((bucket) => bucket.count), + topArtists: artists.slice(0, topLimit).map((artist) => ({ label: artist.name, count: artist.count, color: 'red' })) + }; +} + +export function aniListStats(profile: AniListProfile): AniListStats { + return { + profileUrl: profile.listUrl, + rows: profile.stats, + statuses: profile.statusCounts + .filter((status) => status.count > 0) + .map((status) => ({ label: status.label, count: status.count, color: statusColors[status.status] ?? 'foreground' })) + }; +} diff --git a/src/lib/btop/types.ts b/src/lib/btop/types.ts index fe9ee03..f13127f 100644 --- a/src/lib/btop/types.ts +++ b/src/lib/btop/types.ts @@ -38,3 +38,50 @@ export interface BtopData { contributions: ContributionCalendar; repos: RepoProcess[]; } + +export interface CountStat { + label: string; + count: number; + color: string; +} + +export interface StatRow { + label: string; + value: string; +} + +export interface BookStats { + shelves: CountStat[]; + rows: StatRow[]; + topGenres: CountStat[]; + ratingCounts: number[]; +} + +export interface ScreenStats { + rows: StatRow[]; + ratingCounts: number[]; + watching: string[]; +} + +export interface ListeningStats { + profileUrl: string; + rows: StatRow[]; + daily: number[]; + hourly: number[]; + topArtists: CountStat[]; +} + +export interface AniListStats { + profileUrl: string; + rows: StatRow[]; + statuses: CountStat[]; +} + +export interface HobbyStats { + listening: ListeningStats | null; + books: BookStats | null; + shows: ScreenStats; + movies: ScreenStats; + anime: AniListStats | null; + manga: AniListStats | null; +} diff --git a/src/lib/components/btop/BtopBox.svelte b/src/lib/components/btop/BtopBox.svelte index aaa75d4..d9da3b3 100644 --- a/src/lib/components/btop/BtopBox.svelte +++ b/src/lib/components/btop/BtopBox.svelte @@ -5,11 +5,12 @@ title, number, color = 'accent', + tall = false, children - }: { title: string; number: number; color?: string; children: Snippet } = $props(); + }: { title: string; number: number; color?: string; tall?: boolean; children: Snippet } = $props(); -
+
{number}{title} {@render children()}
diff --git a/src/lib/components/btop/CpuPanel.svelte b/src/lib/components/btop/CpuPanel.svelte index dc33993..3eea6e0 100644 --- a/src/lib/components/btop/CpuPanel.svelte +++ b/src/lib/components/btop/CpuPanel.svelte @@ -52,9 +52,10 @@ .graph { display: flex; + flex: 1; align-items: flex-end; gap: 1px; - height: 70px; + min-height: 70px; margin-top: 0.4rem; border-bottom: 1px solid color-mix(in srgb, var(--foreground) 15%, transparent); } diff --git a/src/lib/components/btop/HobbyPanels.svelte b/src/lib/components/btop/HobbyPanels.svelte new file mode 100644 index 0000000..69f5b8b --- /dev/null +++ b/src/lib/components/btop/HobbyPanels.svelte @@ -0,0 +1,50 @@ + + +{#if stats} +
+ {#if stats.listening} + + {/if} + {#if stats.books} + + {/if} + + + {#if stats.anime} + + {/if} + {#if stats.manga} + + {/if} +
+{/if} + + diff --git a/src/lib/components/btop/SystemMonitor.svelte b/src/lib/components/btop/SystemMonitor.svelte index 42a38b7..03e6e1f 100644 --- a/src/lib/components/btop/SystemMonitor.svelte +++ b/src/lib/components/btop/SystemMonitor.svelte @@ -5,6 +5,7 @@ import MemoryPanel from './MemoryPanel.svelte'; import ContributionPanel from './ContributionPanel.svelte'; import ProcessPanel from './ProcessPanel.svelte'; + import HobbyPanels from './HobbyPanels.svelte'; let data = $state(null); let clock = $state(new Date()); @@ -35,6 +36,8 @@
+ + diff --git a/src/lib/components/btop/charts/BarGraph.svelte b/src/lib/components/btop/charts/BarGraph.svelte new file mode 100644 index 0000000..39164a1 --- /dev/null +++ b/src/lib/components/btop/charts/BarGraph.svelte @@ -0,0 +1,53 @@ + + + + + diff --git a/src/lib/components/btop/charts/CountBars.svelte b/src/lib/components/btop/charts/CountBars.svelte new file mode 100644 index 0000000..1f6d1b4 --- /dev/null +++ b/src/lib/components/btop/charts/CountBars.svelte @@ -0,0 +1,46 @@ + + +
+ {#each items as item (item.label)} + {item.label} + + {item.count} + {/each} +
+ + diff --git a/src/lib/components/btop/charts/SectionLabel.svelte b/src/lib/components/btop/charts/SectionLabel.svelte new file mode 100644 index 0000000..1139275 --- /dev/null +++ b/src/lib/components/btop/charts/SectionLabel.svelte @@ -0,0 +1,16 @@ + + + + + diff --git a/src/lib/components/btop/charts/StackedBar.svelte b/src/lib/components/btop/charts/StackedBar.svelte new file mode 100644 index 0000000..8ddccb2 --- /dev/null +++ b/src/lib/components/btop/charts/StackedBar.svelte @@ -0,0 +1,40 @@ + + +
+ +
+ {#each shown as item (item.label)} + {item.count} {item.label} + {/each} +
+
+ + diff --git a/src/lib/components/btop/charts/StatRows.svelte b/src/lib/components/btop/charts/StatRows.svelte new file mode 100644 index 0000000..6a4595f --- /dev/null +++ b/src/lib/components/btop/charts/StatRows.svelte @@ -0,0 +1,26 @@ + + +
+ {#each rows as row (row.label)} +
{row.label}{row.value}
+ {/each} +
+ + diff --git a/src/lib/components/btop/hobbies/AniListPanel.svelte b/src/lib/components/btop/hobbies/AniListPanel.svelte new file mode 100644 index 0000000..6863565 --- /dev/null +++ b/src/lib/components/btop/hobbies/AniListPanel.svelte @@ -0,0 +1,18 @@ + + + + + + {#if stats.statuses.length} + + + {/if} + diff --git a/src/lib/components/btop/hobbies/BooksPanel.svelte b/src/lib/components/btop/hobbies/BooksPanel.svelte new file mode 100644 index 0000000..53fe49f --- /dev/null +++ b/src/lib/components/btop/hobbies/BooksPanel.svelte @@ -0,0 +1,37 @@ + + + + + + + + + {#if stats.topGenres.length} + + + {/if} + + + +
0510
+
+ + diff --git a/src/lib/components/btop/hobbies/ListeningPanel.svelte b/src/lib/components/btop/hobbies/ListeningPanel.svelte new file mode 100644 index 0000000..ccd307e --- /dev/null +++ b/src/lib/components/btop/hobbies/ListeningPanel.svelte @@ -0,0 +1,27 @@ + + + + + + + + + + + + {#if stats.topArtists.length} + + + {/if} + diff --git a/src/lib/components/btop/hobbies/ScreenPanel.svelte b/src/lib/components/btop/hobbies/ScreenPanel.svelte new file mode 100644 index 0000000..1056145 --- /dev/null +++ b/src/lib/components/btop/hobbies/ScreenPanel.svelte @@ -0,0 +1,49 @@ + + + + + + + +
0510
+ + {#if stats.watching.length} + +
    + {#each stats.watching as name (name)} +
  • > {name}
  • + {/each} +
+ {/if} +
+ + diff --git a/src/lib/server/btopHobbies.ts b/src/lib/server/btopHobbies.ts new file mode 100644 index 0000000..e73dd7e --- /dev/null +++ b/src/lib/server/btopHobbies.ts @@ -0,0 +1,24 @@ +import { about, books } from '#lib/content'; +import type { HobbyStats } from '#lib/btop/types'; +import { aniListStats, bookStats, listeningStats, screenStats } from '#lib/btop/hobbies'; +import { getAniListProfile } from './anilist'; +import { getListeningProfile } from './lastfm/profile'; + +export async function getHobbyStats(): Promise { + const anilistUsername = about.anilistUsername; + const [listening, anime, manga] = await Promise.all([ + about.lastfmUsername ? getListeningProfile(about.lastfmUsername).catch(() => null) : null, + anilistUsername ? getAniListProfile(anilistUsername, 'ANIME').catch(() => null) : null, + anilistUsername ? getAniListProfile(anilistUsername, 'MANGA').catch(() => null) : null + ]); + const hasManga = !!manga?.statusCounts.some((status) => status.count > 0); + + return { + listening: listening ? listeningStats(listening) : null, + books: bookStats(books), + shows: screenStats(about.shows), + movies: screenStats(about.movies), + anime: anime ? aniListStats(anime) : null, + manga: manga && hasManga ? aniListStats(manga) : null + }; +} diff --git a/src/routes/api/btop/hobbies/+server.ts b/src/routes/api/btop/hobbies/+server.ts new file mode 100644 index 0000000..c31e407 --- /dev/null +++ b/src/routes/api/btop/hobbies/+server.ts @@ -0,0 +1,7 @@ +import { json } from '@sveltejs/kit'; +import { getHobbyStats } from '#lib/server/btopHobbies'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async () => { + return json(await getHobbyStats(), { headers: { 'Cache-Control': 'no-cache' } }); +};