music: Last.fm-style stats with 7d/30d/1y/all ranges and genres
All checks were successful
Deploy production / deploy (push) Successful in 1m14s
All checks were successful
Deploy production / deploy (push) Successful in 1m14s
- Range picker (?range=7d|30d|1y|all) drives every section; the hours chart covers the whole range (daily for 7d/30d, weekly for 1y, monthly for all). - Genres from Last.fm artist top tags (music.artist_tags, normalised and filtered), split by artist listening time, with a per-bucket stacked chart. - Listening clock, new artists, comparison with the previous period and top movers, streaks, milestones, top albums with art, rank changes. - music.play_facts materialized view (refreshed by the app when plays arrive) plus a per-range cache keep the page fast. - scripts/music-fetch-tags.ts backfills tags, most-played artists first.
This commit is contained in:
157
src/lib/music/HoursChart.svelte
Normal file
157
src/lib/music/HoursChart.svelte
Normal file
@@ -0,0 +1,157 @@
|
||||
<script lang="ts" module>
|
||||
export type ChartSegment = { name: string; hours: number; color: string };
|
||||
export type ChartBar = {
|
||||
key: string;
|
||||
/** Tooltip title, e.g. the date or "week of 19 Oct 2026". */
|
||||
title: string;
|
||||
hours: number;
|
||||
plays?: number;
|
||||
/** Stacked parts (bottom first); the rest of `hours` is drawn in the base colour. */
|
||||
segments?: ChartSegment[];
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
// Bar chart of listening hours with a styled tooltip on hover, keyboard focus or tap
|
||||
// (roving tabindex: arrow keys / Home / End move between bars, Escape closes).
|
||||
let {
|
||||
bars,
|
||||
label,
|
||||
startLabel = '',
|
||||
endLabel = '',
|
||||
baseColor = '#1db954',
|
||||
height = 'h-32'
|
||||
}: {
|
||||
bars: ChartBar[];
|
||||
label: string;
|
||||
startLabel?: string;
|
||||
endLabel?: string;
|
||||
baseColor?: string;
|
||||
height?: string;
|
||||
} = $props();
|
||||
|
||||
let active = $state<number | null>(null);
|
||||
let focused = $state<number | null>(null);
|
||||
let buttons = $state<HTMLButtonElement[]>([]);
|
||||
// Whether the tapped bar was already open before this tap (focus opens it first).
|
||||
let wasOpen = false;
|
||||
const max = $derived(Math.max(0.01, ...bars.map((b) => b.hours)));
|
||||
const tabIndexAt = $derived(focused ?? bars.length - 1);
|
||||
const tip = $derived(active === null ? null : bars[active]);
|
||||
const exactHours = (h: number) =>
|
||||
`${h.toLocaleString('en-GB', { minimumFractionDigits: 1, maximumFractionDigits: 1 })} h`;
|
||||
const shift = (pos: number) => (pos < 0.15 ? '-10%' : pos > 0.85 ? '-90%' : '-50%');
|
||||
const pct = (h: number) => (h / max) * 100;
|
||||
|
||||
function move(event: KeyboardEvent, i: number) {
|
||||
if (event.key === 'Escape') {
|
||||
active = null;
|
||||
return;
|
||||
}
|
||||
const last = bars.length - 1;
|
||||
const next: Record<string, number> = {
|
||||
ArrowLeft: Math.max(0, i - 1),
|
||||
ArrowRight: Math.min(last, i + 1),
|
||||
Home: 0,
|
||||
End: last
|
||||
};
|
||||
if (!(event.key in next)) return;
|
||||
event.preventDefault();
|
||||
focused = next[event.key];
|
||||
buttons[focused]?.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative">
|
||||
<div
|
||||
class="flex {height} items-end gap-px rounded-md border border-[var(--site-border)] p-2"
|
||||
role="group"
|
||||
aria-label={label}
|
||||
onpointerleave={(e) => {
|
||||
if (e.pointerType === 'mouse') active = null;
|
||||
}}
|
||||
>
|
||||
{#each bars as bar, i (bar.key)}
|
||||
{@const segs = bar.segments ?? []}
|
||||
{@const rest = Math.max(0, bar.hours - segs.reduce((sum, s) => sum + s.hours, 0))}
|
||||
<button
|
||||
type="button"
|
||||
bind:this={buttons[i]}
|
||||
class="group flex h-full min-w-0 flex-1 cursor-default flex-col-reverse rounded-sm focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[var(--site-text)]"
|
||||
tabindex={i === tabIndexAt ? 0 : -1}
|
||||
aria-label="{bar.title}: {exactHours(bar.hours)}{bar.plays != null
|
||||
? `, ${bar.plays} plays`
|
||||
: ''}"
|
||||
onpointerenter={(e) => {
|
||||
if (e.pointerType === 'mouse') active = i;
|
||||
}}
|
||||
onpointerdown={(e) => {
|
||||
wasOpen = e.pointerType !== 'mouse' && active === i;
|
||||
}}
|
||||
onclick={() => {
|
||||
active = wasOpen ? null : i;
|
||||
wasOpen = false;
|
||||
}}
|
||||
onfocus={() => {
|
||||
focused = i;
|
||||
active = i;
|
||||
}}
|
||||
onblur={() => {
|
||||
if (active === i) active = null;
|
||||
}}
|
||||
onkeydown={(e) => move(e, i)}
|
||||
>
|
||||
<span
|
||||
class="flex w-full flex-col-reverse overflow-hidden rounded-sm transition-opacity {active ===
|
||||
i
|
||||
? 'opacity-100'
|
||||
: 'opacity-80 group-hover:opacity-100'}"
|
||||
style="height: {Math.max(bar.hours > 0 ? 3 : 0, pct(bar.hours))}%"
|
||||
>
|
||||
{#each segs as seg (seg.name)}
|
||||
<span
|
||||
class="block w-full shrink-0"
|
||||
style="height: {(seg.hours / Math.max(bar.hours, 0.0001)) *
|
||||
100}%; background: {seg.color}"
|
||||
></span>
|
||||
{/each}
|
||||
{#if rest > 0}
|
||||
<span class="block w-full flex-1" style="background: {baseColor}"></span>
|
||||
{/if}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if tip && active !== null}
|
||||
{@const pos = (active + 0.5) / bars.length}
|
||||
<div
|
||||
class="pointer-events-none absolute bottom-full z-10 mb-2 rounded-md border border-[var(--site-border)] bg-[var(--site-surface)] px-3 py-1.5 text-sm whitespace-nowrap shadow-lg"
|
||||
style="left: calc(0.5rem + {pos} * (100% - 1rem)); transform: translateX({shift(pos)})"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="block font-semibold tabular-nums">{exactHours(tip.hours)}</span>
|
||||
<span class="block text-xs text-[var(--site-muted)]"
|
||||
>{tip.title}{#if tip.plays != null}
|
||||
· {tip.plays.toLocaleString('en-GB')} {tip.plays === 1 ? 'play' : 'plays'}{/if}</span
|
||||
>
|
||||
{#if tip.segments?.length}
|
||||
<ul class="mt-1 space-y-0.5 text-xs">
|
||||
{#each [...tip.segments].reverse() as seg (seg.name)}
|
||||
<li class="flex items-center gap-1.5">
|
||||
<span class="size-2 rounded-full" style="background: {seg.color}"></span>
|
||||
{seg.name}
|
||||
<span class="ml-auto pl-3 tabular-nums text-[var(--site-muted)]"
|
||||
>{exactHours(seg.hours)}</span
|
||||
>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if startLabel || endLabel}
|
||||
<div class="mt-1 flex justify-between text-xs text-[var(--site-muted)]">
|
||||
<span>{startLabel}</span><span>{endLabel}</span>
|
||||
</div>
|
||||
{/if}
|
||||
51
src/lib/music/tags.test.ts
Normal file
51
src/lib/music/tags.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { normalizeTag, pickTags } from './tags';
|
||||
|
||||
describe('normalizeTag', () => {
|
||||
it('merges spelling variants', () => {
|
||||
expect(normalizeTag('Hip-Hop')).toBe('hip-hop');
|
||||
expect(normalizeTag('hiphop')).toBe('hip-hop');
|
||||
expect(normalizeTag('Hip Hop')).toBe('hip-hop');
|
||||
expect(normalizeTag('Nu Metal')).toBe('nu metal');
|
||||
expect(normalizeTag('nu-metal')).toBe('nu metal');
|
||||
expect(normalizeTag('Post Rock')).toBe('post-rock');
|
||||
expect(normalizeTag('post - rock')).toBe('post-rock');
|
||||
expect(normalizeTag('Drum n Bass')).toBe('drum and bass');
|
||||
expect(normalizeTag(' Progressive Metal ')).toBe('progressive metal');
|
||||
});
|
||||
|
||||
it('drops junk tags', () => {
|
||||
for (const junk of [
|
||||
'seen live',
|
||||
'favorites',
|
||||
'My Favourite',
|
||||
'albums I own',
|
||||
'80s',
|
||||
'2010s',
|
||||
'british',
|
||||
'female vocalists',
|
||||
'spotify'
|
||||
])
|
||||
expect(normalizeTag(junk)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickTags', () => {
|
||||
it('keeps the top genres above the minimum count, merged', () => {
|
||||
expect(
|
||||
pickTags([
|
||||
{ name: 'nu metal', count: 100 },
|
||||
{ name: 'seen live', count: 80 },
|
||||
{ name: 'Nu-Metal', count: 60 },
|
||||
{ name: 'metal', count: 55 },
|
||||
{ name: 'alternative metal', count: 40 },
|
||||
{ name: 'american', count: 30 },
|
||||
{ name: 'rock', count: 5 }
|
||||
])
|
||||
).toEqual([
|
||||
{ tag: 'nu metal', weight: 100 },
|
||||
{ tag: 'metal', weight: 55 },
|
||||
{ tag: 'alternative metal', weight: 40 }
|
||||
]);
|
||||
});
|
||||
});
|
||||
124
src/lib/music/tags.ts
Normal file
124
src/lib/music/tags.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
// Last.fm artist tags → genres: normalise spelling variants and drop tags that are not a
|
||||
// genre (seen live, favourites, nationalities, decades, ...). Pure, shared by
|
||||
// scripts/music-fetch-tags.ts and the tests.
|
||||
|
||||
const ALIASES: Record<string, string> = {
|
||||
hiphop: 'hip-hop',
|
||||
'hip hop': 'hip-hop',
|
||||
rap: 'hip-hop',
|
||||
rnb: 'r&b',
|
||||
'r and b': 'r&b',
|
||||
'rhythm and blues': 'r&b',
|
||||
dnb: 'drum and bass',
|
||||
'drum n bass': 'drum and bass',
|
||||
'drum & bass': 'drum and bass',
|
||||
'j pop': 'j-pop',
|
||||
jpop: 'j-pop',
|
||||
'k pop': 'k-pop',
|
||||
kpop: 'k-pop',
|
||||
'j rock': 'j-rock',
|
||||
'lo fi': 'lo-fi',
|
||||
lofi: 'lo-fi',
|
||||
'alt rock': 'alternative rock',
|
||||
alternative: 'alternative rock',
|
||||
'nu-metal': 'nu metal',
|
||||
numetal: 'nu metal',
|
||||
electronica: 'electronic',
|
||||
electro: 'electronic',
|
||||
'video game': 'video game music',
|
||||
'video games': 'video game music',
|
||||
'videogame music': 'video game music',
|
||||
'game music': 'video game music',
|
||||
vgm: 'video game music',
|
||||
ost: 'soundtrack',
|
||||
'original soundtrack': 'soundtrack',
|
||||
'singer songwriter': 'singer-songwriter',
|
||||
'post rock': 'post-rock',
|
||||
'post punk': 'post-punk',
|
||||
'post hardcore': 'post-hardcore',
|
||||
'pop punk': 'pop punk',
|
||||
'synth pop': 'synthpop',
|
||||
'synth-pop': 'synthpop',
|
||||
'trip hop': 'trip-hop'
|
||||
};
|
||||
|
||||
const JUNK = new Set([
|
||||
'seen live',
|
||||
'live',
|
||||
'albums i own',
|
||||
'under 2000 listeners',
|
||||
'spotify',
|
||||
'love',
|
||||
'loved',
|
||||
'beautiful',
|
||||
'awesome',
|
||||
'amazing',
|
||||
'cool',
|
||||
'good',
|
||||
'great',
|
||||
'best',
|
||||
'epic',
|
||||
'fun',
|
||||
'sexy',
|
||||
'genius',
|
||||
'legend',
|
||||
'legends',
|
||||
'overrated',
|
||||
'underrated',
|
||||
'check out',
|
||||
'to listen',
|
||||
'male vocalists',
|
||||
'female vocalists',
|
||||
'male vocalist',
|
||||
'female vocalist',
|
||||
'male vocals',
|
||||
'female vocals',
|
||||
'vocal',
|
||||
'vocals',
|
||||
'all',
|
||||
'music',
|
||||
'other',
|
||||
'misc',
|
||||
'unknown',
|
||||
'instrumental music',
|
||||
'band',
|
||||
'bands',
|
||||
'artist',
|
||||
'composer',
|
||||
'dj',
|
||||
'producer'
|
||||
]);
|
||||
|
||||
const PLACES =
|
||||
/^(american|british|english|scottish|irish|welsh|uk|usa|us|german|deutsch|japanese|japan|french|swedish|swede|dutch|nederlands|belgian|canadian|australian|finnish|norwegian|danish|polish|russian|korean|italian|spanish|brazilian|mexican|icelandic|chinese|ukrainian|austrian|swiss|new zealand|los angeles|california|london|seattle)$/;
|
||||
const JUNK_PATTERN =
|
||||
/favou?rite|favs?\b|seen live|\bi own\b|i like|my |listened|wishlist|check out|youtube|spotify|soundcloud|^\d{2,4}s?$|^(19|20)\d\d\b|\bnoughties\b|best of|top \d/;
|
||||
|
||||
/** Canonical genre name, or null when the tag is not a genre. */
|
||||
export function normalizeTag(raw: string): string | null {
|
||||
let tag = raw
|
||||
.toLowerCase()
|
||||
.normalize('NFKC')
|
||||
.replace(/[_]+/g, ' ')
|
||||
.replace(/\s*-\s*/g, '-')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
if (!tag || tag.length > 40 || tag.length < 2) return null;
|
||||
tag = ALIASES[tag] ?? ALIASES[tag.replace(/-/g, ' ')] ?? tag;
|
||||
if (JUNK.has(tag) || PLACES.test(tag) || JUNK_PATTERN.test(tag)) return null;
|
||||
return tag;
|
||||
}
|
||||
|
||||
/** Top genres from Last.fm's [{ name, count }] (count 0–100): merged variants, best first. */
|
||||
export function pickTags(raw: { name: string; count: number }[], max = 5, minCount = 10) {
|
||||
const best = new Map<string, number>();
|
||||
for (const { name, count } of raw) {
|
||||
const tag = normalizeTag(name);
|
||||
if (!tag || !(count >= minCount)) continue;
|
||||
best.set(tag, Math.max(best.get(tag) ?? 0, Math.min(100, Math.round(count))));
|
||||
}
|
||||
return [...best]
|
||||
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
||||
.slice(0, max)
|
||||
.map(([tag, weight]) => ({ tag, weight }));
|
||||
}
|
||||
Reference in New Issue
Block a user