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

+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>