music: Last.fm-style stats with 7d/30d/1y/all ranges and genres
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:
2026-10-25 14:03:39 +00:00
parent 5e4cd83748
commit 516bc15545
9 changed files with 27811 additions and 229 deletions

View 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}

View 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
View 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 }));
}