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 planningCount = titles.filter((title) => title.status === 'planning').length; const groups = new Set(titles.flatMap((title) => (title.collection ? [title.collection] : []))); return { rows: [ { label: 'Titles', value: `${titles.length}` }, { label: 'Watching', value: `${watching.length}` }, { label: 'Planning', value: `${planningCount}` }, { 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' })) }; }