Add hobby stats and graphs to btop

This commit is contained in:
SirBlob committed 2026-11-01 21:50:05 -05:00
1 parent 4a52320d4e
commit 5f5d24cb2d
17 files changed
+571 -13

No files matched your search

+102
View File
@@ -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<string, string> = {
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<number>(11).fill(0);
ratings.forEach((rating) => counts[Math.round(rating)]++);
return counts;
}
function topCounts(values: string[], color: string): CountStat[] {
const counts = new Map<string, number>();
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' }))
};
}
+47
View File
@@ -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;
}
+3 -2
View File
@@ -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();
</script>
<section class="btop-box" style:--box-color="var(--{color})">
<section class="btop-box" class:tall style:--box-color="var(--{color})">
<span class="box-title"><sup>{number}</sup>{title}</span>
{@render children()}
</section>
+2 -1
View File
@@ -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);
}
@@ -0,0 +1,50 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { HobbyStats } from '#lib/btop/types';
import ListeningPanel from './hobbies/ListeningPanel.svelte';
import BooksPanel from './hobbies/BooksPanel.svelte';
import ScreenPanel from './hobbies/ScreenPanel.svelte';
import AniListPanel from './hobbies/AniListPanel.svelte';
let stats = $state<HobbyStats | null>(null);
onMount(() => {
fetch('/api/btop/hobbies')
.then((response) => (response.ok ? response.json() : null))
.then((result) => (stats = result))
.catch(() => (stats = null));
});
</script>
{#if stats}
<div class="hobby-panels">
{#if stats.listening}
<ListeningPanel stats={stats.listening} number={5} />
{/if}
{#if stats.books}
<BooksPanel stats={stats.books} number={6} />
{/if}
<ScreenPanel stats={stats.shows} title="tv" number={7} color="yellow" />
<ScreenPanel stats={stats.movies} title="movies" number={8} color="green" />
{#if stats.anime}
<AniListPanel stats={stats.anime} title="anime" number={9} color="magenta" />
{/if}
{#if stats.manga}
<AniListPanel stats={stats.manga} title="manga" number={10} color="blue" />
{/if}
</div>
{/if}
<style>
.hobby-panels {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, max(240px, 25% - 0.75rem)), 1fr));
grid-auto-flow: dense;
gap: 1rem 0.75rem;
padding-top: 0.4rem;
}
.hobby-panels > :global(.btop-box.tall) {
grid-row: span 2;
}
</style>
+24 -10
View File
@@ -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<BtopData | null>(null);
let clock = $state(new Date());
@@ -35,6 +36,8 @@
<div class="mem"><MemoryPanel {data} /></div>
<div class="proc"><ProcessPanel {data} /></div>
</div>
<HobbyPanels />
</div>
<style>
@@ -45,6 +48,7 @@
height: 100%;
min-height: 0;
padding: 0.5rem 0.75rem 0.75rem;
overflow-y: auto;
container-type: inline-size;
}
@@ -62,14 +66,28 @@
.panels {
display: grid;
flex: 1;
flex: none;
grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
grid-template-rows: auto minmax(0, 1fr);
grid-template-rows: auto auto;
grid-template-areas:
'cpu net'
'proc mem';
gap: 1rem 0.75rem;
min-height: 0;
}
.cpu,
.net,
.mem,
.proc {
display: flex;
min-width: 0;
}
.cpu > :global(.btop-box),
.net > :global(.btop-box),
.mem > :global(.btop-box),
.proc > :global(.btop-box) {
flex: 1;
}
.cpu {
@@ -86,12 +104,8 @@
.proc {
grid-area: proc;
display: flex;
min-height: 0;
}
.proc > :global(.btop-box) {
flex: 1;
min-height: 220px;
contain: size;
}
@container (max-width: 760px) {
@@ -106,7 +120,7 @@
}
.proc {
min-height: 220px;
min-height: 260px;
}
}
</style>
@@ -0,0 +1,53 @@
<script lang="ts">
let {
values,
color,
height = 56,
labels = [],
grow = false
}: { values: number[]; color: string; height?: number; labels?: string[]; grow?: boolean } = $props();
const minimumScale = 0.02;
const highest = $derived(Math.max(1, ...(values ?? [])));
function barScale(value: number): number {
return Math.max(minimumScale, value / highest);
}
</script>
<div class="bar-graph" class:grow style:min-height="{height}px" style:height={grow ? null : `${height}px`} aria-hidden="true">
{#each values ?? [] as value, index (index)}
<span
class="bar"
class:empty={value === 0}
style:transform="scaleY({barScale(value)})"
style:background="var(--{color})"
title={labels[index] ? `${labels[index]}: ${value}` : `${value}`}
></span>
{/each}
</div>
<style>
.bar-graph {
display: flex;
align-items: flex-end;
gap: 1px;
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 15%, transparent);
}
.bar-graph.grow {
flex: 1;
}
.bar {
flex: 1;
align-self: stretch;
opacity: 0.85;
transform-origin: bottom;
}
.bar.empty {
opacity: 0.25;
}
</style>
@@ -0,0 +1,46 @@
<script lang="ts">
import type { CountStat } from '#lib/btop/types';
let { items }: { items: CountStat[] } = $props();
const highest = $derived(Math.max(1, ...items.map((item) => item.count)));
</script>
<div class="count-bars">
{#each items as item (item.label)}
<span class="label" title={item.label}>{item.label}</span>
<span class="track"><span style:width="{(item.count / highest) * 100}%" style:background="var(--{item.color})"></span></span>
<span class="count" style:color="var(--{item.color})">{item.count}</span>
{/each}
</div>
<style>
.count-bars {
display: grid;
grid-template-columns: minmax(0, 7.5rem) minmax(0, 1fr) auto;
align-items: center;
gap: 0.25rem 0.5rem;
font-size: 0.75rem;
}
.label {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.track {
display: block;
height: 6px;
background: color-mix(in srgb, var(--foreground) 10%, transparent);
}
.track span {
display: block;
height: 100%;
}
.count {
text-align: right;
}
</style>
@@ -0,0 +1,16 @@
<script lang="ts">
let { text, color }: { text: string; color: string } = $props();
</script>
<span class="section-label" style:color="var(--{color})">{text}</span>
<style>
.section-label {
display: block;
margin: 0.6rem 0 0.3rem;
padding-top: 0.45rem;
border-top: 1px dashed color-mix(in srgb, var(--foreground) 20%, transparent);
font-size: 0.72rem;
font-weight: 700;
}
</style>
@@ -0,0 +1,40 @@
<script lang="ts">
import type { CountStat } from '#lib/btop/types';
let { items }: { items: CountStat[] } = $props();
const shown = $derived(items.filter((item) => item.count > 0));
</script>
<div class="stacked">
<div class="bar" role="img" aria-label={shown.map((item) => `${item.label}: ${item.count}`).join(', ')}>
{#each shown as item (item.label)}
<span style:flex-grow={item.count} style:background="var(--{item.color})"></span>
{/each}
</div>
<div class="legend">
{#each shown as item (item.label)}
<span style:color="var(--{item.color})">{item.count} {item.label}</span>
{/each}
</div>
</div>
<style>
.bar {
display: flex;
gap: 2px;
height: 8px;
}
.bar span {
flex-basis: 0;
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 0.2rem 0.75rem;
margin-top: 0.35rem;
font-size: 0.72rem;
}
</style>
@@ -0,0 +1,26 @@
<script lang="ts">
import type { StatRow } from '#lib/btop/types';
let { rows }: { rows: StatRow[] } = $props();
</script>
<div class="stat-rows">
{#each rows as row (row.label)}
<div class="row"><span class="muted">{row.label}</span><span>{row.value}</span></div>
{/each}
</div>
<style>
.stat-rows {
display: flex;
flex-direction: column;
gap: 0.15rem;
font-size: 0.75rem;
}
.row {
display: flex;
justify-content: space-between;
gap: 0.75rem;
}
</style>
@@ -0,0 +1,18 @@
<script lang="ts">
import type { AniListStats } from '#lib/btop/types';
import BtopBox from '../BtopBox.svelte';
import CountBars from '../charts/CountBars.svelte';
import StatRows from '../charts/StatRows.svelte';
import SectionLabel from '../charts/SectionLabel.svelte';
let { stats, title, number, color }: { stats: AniListStats; title: string; number: number; color: string } = $props();
</script>
<BtopBox {title} {number} {color}>
<StatRows rows={stats.rows} />
{#if stats.statuses.length}
<SectionLabel text="lists" {color} />
<CountBars items={stats.statuses} />
{/if}
</BtopBox>
@@ -0,0 +1,37 @@
<script lang="ts">
import type { BookStats } from '#lib/btop/types';
import BtopBox from '../BtopBox.svelte';
import BarGraph from '../charts/BarGraph.svelte';
import CountBars from '../charts/CountBars.svelte';
import StackedBar from '../charts/StackedBar.svelte';
import StatRows from '../charts/StatRows.svelte';
import SectionLabel from '../charts/SectionLabel.svelte';
let { stats, number }: { stats: BookStats; number: number } = $props();
const ratingLabels = Array.from({ length: 11 }, (_, rating) => `${rating}/10`);
</script>
<BtopBox title="books" {number} color="cyan" tall>
<StackedBar items={stats.shelves} />
<SectionLabel text="reading" color="cyan" />
<StatRows rows={stats.rows} />
{#if stats.topGenres.length}
<SectionLabel text="top genres" color="cyan" />
<CountBars items={stats.topGenres} />
{/if}
<SectionLabel text="ratings" color="cyan" />
<BarGraph values={stats.ratingCounts} labels={ratingLabels} color="cyan" height={44} grow />
<div class="axis muted"><span>0</span><span>5</span><span>10</span></div>
</BtopBox>
<style>
.axis {
display: flex;
justify-content: space-between;
font-size: 0.65rem;
}
</style>
@@ -0,0 +1,27 @@
<script lang="ts">
import type { ListeningStats } from '#lib/btop/types';
import BtopBox from '../BtopBox.svelte';
import BarGraph from '../charts/BarGraph.svelte';
import CountBars from '../charts/CountBars.svelte';
import StatRows from '../charts/StatRows.svelte';
import SectionLabel from '../charts/SectionLabel.svelte';
let { stats, number }: { stats: ListeningStats; number: number } = $props();
const hourLabels = Array.from({ length: 24 }, (_, hour) => `${hour % 12 || 12}${hour < 12 ? 'am' : 'pm'}`);
</script>
<BtopBox title="music" {number} color="red" tall>
<StatRows rows={stats.rows} />
<SectionLabel text="scrobbles, last {stats.daily.length} days" color="red" />
<BarGraph values={stats.daily} color="red" grow />
<SectionLabel text="by hour of day" color="red" />
<BarGraph values={stats.hourly} labels={hourLabels} color="magenta" height={36} />
{#if stats.topArtists.length}
<SectionLabel text="top artists" color="red" />
<CountBars items={stats.topArtists} />
{/if}
</BtopBox>
@@ -0,0 +1,49 @@
<script lang="ts">
import type { ScreenStats } from '#lib/btop/types';
import BtopBox from '../BtopBox.svelte';
import BarGraph from '../charts/BarGraph.svelte';
import StatRows from '../charts/StatRows.svelte';
import SectionLabel from '../charts/SectionLabel.svelte';
let { stats, title, number, color }: { stats: ScreenStats; title: string; number: number; color: string } = $props();
const ratingLabels = Array.from({ length: 11 }, (_, rating) => `${rating}/10`);
</script>
<BtopBox {title} {number} {color}>
<StatRows rows={stats.rows} />
<SectionLabel text="ratings" {color} />
<BarGraph values={stats.ratingCounts} labels={ratingLabels} {color} height={44} grow />
<div class="axis muted"><span>0</span><span>5</span><span>10</span></div>
{#if stats.watching.length}
<SectionLabel text="watching now" {color} />
<ul class="watching">
{#each stats.watching as name (name)}
<li><span style:color="var(--blue)">&gt;</span> {name}</li>
{/each}
</ul>
{/if}
</BtopBox>
<style>
.axis {
display: flex;
justify-content: space-between;
font-size: 0.65rem;
}
.watching {
margin: 0;
padding: 0;
list-style: none;
font-size: 0.75rem;
}
.watching li {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
</style>
+24
View File
@@ -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<HobbyStats> {
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
};
}
+7
View File
@@ -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' } });
};