Add hobby stats and graphs to btop
This commit is contained in:
1 parent
4a52320d4e
commit
5f5d24cb2d
17 files changed
+571
-13
No files matched your search
@@ -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' }))
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user