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

@@ -1,4 +1,5 @@
<script lang="ts">
import HoursChart, { type ChartBar } from '#lib/music/HoursChart.svelte';
import ListeningCard from '#lib/music/ListeningCard.svelte';
import { relativeTime } from '#lib/relative-time.js';
import Music from 'lucide-svelte/icons/music';
@@ -9,15 +10,89 @@
const ranges = [
['7d', '7 days'],
['30d', '30 days'],
['1y', '1 year'],
['all', 'all time']
] as const;
const maxHours = $derived(Math.max(0.01, ...s.daily.map((d) => d.hours)));
const periodName = $derived(
{ '7d': '7 days', '30d': '30 days', '1y': 'year', all: '' }[s.range] ?? ''
);
const unitName = $derived({ day: 'day', week: 'week', month: 'month' }[s.unit]);
const GENRE_COLORS = ['#1db954', '#3b82f6', '#f59e0b', '#ef4444', '#a855f7', '#14b8a6'];
const OTHER_COLOR = 'color-mix(in srgb, var(--site-muted) 45%, transparent)';
const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const nf = (n: number) => n.toLocaleString('en-GB');
const fmtHours = (h: number) =>
h >= 10
? Math.round(h).toLocaleString('en-GB')
: h.toLocaleString('en-GB', { maximumFractionDigits: 1 });
const dayLabel = (day: string) =>
new Date(`${day}T12:00:00Z`).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
h >= 10 ? nf(Math.round(h)) : h.toLocaleString('en-GB', { maximumFractionDigits: 1 });
const date = (day: string, opts: Intl.DateTimeFormatOptions) =>
new Date(`${day.slice(0, 10)}T12:00:00Z`).toLocaleDateString('en-GB', {
timeZone: 'UTC',
...opts
});
const shortDay = (day: string) => date(day, { day: 'numeric', month: 'short' });
const bucketTitle = (bucket: string) =>
s.unit === 'month'
? date(bucket, { month: 'long', year: 'numeric' })
: s.unit === 'week'
? `week of ${date(bucket, { day: 'numeric', month: 'short', year: 'numeric' })}`
: date(bucket, { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' });
const bucketShort = (bucket: string) =>
s.unit === 'month' ? date(bucket, { month: 'short', year: 'numeric' }) : shortDay(bucket);
const change = (now: number, before: number) => {
if (!before) return now ? 'new' : '';
const pct = Math.round(((now - before) / before) * 100);
return `${pct > 0 ? '+' : ''}${pct}%`;
};
const changeClass = (now: number, before: number) =>
now >= before ? 'text-[#1db954]' : 'text-red-500';
const hoursBars = $derived<ChartBar[]>(
s.chart.map((b) => ({
key: b.bucket,
title: bucketTitle(b.bucket),
hours: b.hours,
plays: b.plays
}))
);
const genreColor = $derived(
Object.fromEntries(s.genres.series.map((g, i) => [g, GENRE_COLORS[i % GENRE_COLORS.length]]))
);
const genreBars = $derived<ChartBar[]>(
s.genres.timeline.map((b) => ({
key: b.bucket,
title: bucketTitle(b.bucket),
hours: b.hours,
segments: s.genres.series
.filter((g) => (b.tags[g] ?? 0) > 0)
.map((g) => ({ name: g, hours: b.tags[g], color: genreColor[g] }))
}))
);
const maxGenre = $derived(Math.max(0.01, ...s.genres.top.map((g) => g.hours)));
const clockCells = $derived.by(() => {
const grid = Array.from({ length: 7 }, () =>
Array.from({ length: 24 }, () => ({ hours: 0, plays: 0 }))
);
for (const c of s.clock) grid[c.dow - 1][c.hour] = { hours: c.hours, plays: c.plays };
return grid;
});
const maxCell = $derived(Math.max(0.01, ...s.clock.map((c) => c.hours)));
const peak = $derived(s.clock.reduce((a, b) => (b.hours > (a?.hours ?? 0) ? b : a), s.clock[0]));
let clockHover = $state<{ dow: number; hour: number } | null>(null);
const clockText = $derived.by(() => {
const c = clockHover ?? (peak ? { dow: peak.dow - 1, hour: peak.hour } : null);
if (!c) return '';
const cell = clockCells[c.dow][c.hour];
const label = `${DAYS[c.dow]} ${String(c.hour).padStart(2, '0')}:00–${String((c.hour + 1) % 24).padStart(2, '0')}:00`;
return `${clockHover ? '' : 'peak: '}${label} · ${fmtHours(cell.hours)} h · ${nf(cell.plays)} plays`;
});
function pickCell(event: PointerEvent) {
const cell = (event.target as HTMLElement).dataset?.cell;
if (!cell) return;
const [dow, hour] = cell.split(',').map(Number);
clockHover = { dow, hour };
}
const maxDiscovered = $derived(Math.max(1, ...s.discoveredByYear.map((d) => d.artists)));
const perDay = $derived(s.totals.hours / Math.max(1, s.totals.spanDays));
const sourceLabel: Record<string, string> = {
spotify: 'Spotify',
lastfm: 'Last.fm',
@@ -25,43 +100,6 @@
local: 'local files'
};
const now = Date.now();
// Hours-per-day chart: a styled tooltip on hover, keyboard focus or tap (roving tabindex,
// arrow keys move between days, Escape closes).
let activeDay = $state<number | null>(null);
let focusDay = $state<number | null>(null);
// Whether the tapped bar was already open before this tap (focus opens it first).
let wasOpen = false;
let bars = $state<HTMLButtonElement[]>([]);
const tabDay = $derived(focusDay ?? s.daily.length - 1);
const tipDay = $derived(activeDay === null ? null : s.daily[activeDay]);
const fullDayLabel = (day: string) =>
new Date(`${day}T12:00:00Z`).toLocaleDateString('en-GB', {
weekday: 'short',
day: 'numeric',
month: 'short',
year: 'numeric'
});
const exactHours = (h: number) =>
`${h.toLocaleString('en-GB', { minimumFractionDigits: 1, maximumFractionDigits: 1 })} h`;
const tipShift = (pos: number) => (pos < 0.15 ? '-10%' : pos > 0.85 ? '-90%' : '-50%');
function moveDay(event: KeyboardEvent, i: number) {
if (event.key === 'Escape') {
activeDay = null;
return;
}
const last = s.daily.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();
focusDay = next[event.key];
bars[focusDay]?.focus();
}
</script>
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
@@ -69,7 +107,7 @@
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">music</h1>
<p class="mt-3 max-w-2xl text-[var(--site-muted)]">
Everything I listen to, scrobbled from Spotify every minute into my own database (a
self-hosted Last.fm), plus my older Last.fm history.
self-hosted Last.fm), with my full Spotify history since 2018 and my Last.fm scrobbles.
</p>
</header>
@@ -122,156 +160,416 @@
{#if s.totals.plays === 0}
<div
class="rounded-md border border-dashed border-[var(--site-border)] p-10 text-center text-[var(--site-muted)]"
class="mb-10 rounded-md border border-dashed border-[var(--site-border)] p-10 text-center text-[var(--site-muted)]"
>
<Music size={28} class="mx-auto mb-3" aria-hidden="true" />
No plays {s.range === 'all' ? 'yet' : 'in this period'}. Scrobbling starts as soon as Spotify
is connected.
No plays {s.range === 'all' ? 'yet' : 'in this period'}.
</div>
{:else}
<dl class="mb-10 grid grid-cols-2 gap-3 sm:grid-cols-4">
<div class="stat">
<dt>plays</dt>
<dd>{s.totals.plays.toLocaleString('en-GB')}</dd>
<dd>
{nf(s.totals.plays)}
{#if s.previous}<span class="delta {changeClass(s.totals.plays, s.previous.plays)}"
>{change(s.totals.plays, s.previous.plays)}</span
>{/if}
</dd>
</div>
<div class="stat">
<dt>hours</dt>
<dd>{fmtHours(s.totals.hours)}</dd>
<dd>
{fmtHours(s.totals.hours)}
{#if s.previous}<span class="delta {changeClass(s.totals.hours, s.previous.hours)}"
>{change(s.totals.hours, s.previous.hours)}</span
>{/if}
<span class="sub">{fmtHours(perDay)} h a day</span>
</dd>
</div>
<div class="stat">
<dt>artists</dt>
<dd>{s.totals.artists.toLocaleString('en-GB')}</dd>
<dd>
{nf(s.totals.artists)}
{#if s.previous}<span class="delta {changeClass(s.totals.artists, s.previous.artists)}"
>{change(s.totals.artists, s.previous.artists)}</span
>{/if}
<span class="sub">{nf(s.totals.tracks)} tracks · {nf(s.totals.albums)} albums</span>
</dd>
</div>
<div class="stat">
<dt>streak</dt>
<dd>
{s.streak.current} day{s.streak.current === 1 ? '' : 's'}
<span class="block text-xs font-normal text-[var(--site-muted)]"
<span class="sub"
>longest {s.streak.longest}{#if s.streak.longestStart && s.streak.longest > 1}
({dayLabel(s.streak.longestStart)}–{dayLabel(s.streak.longestEnd ?? '')}){/if}</span
({shortDay(s.streak.longestStart)}
{date(s.streak.longestStart, {
year: 'numeric'
})}–{shortDay(s.streak.longestEnd ?? '')}
{date(s.streak.longestEnd ?? s.streak.longestStart, { year: 'numeric' })}){/if}
· listened {nf(s.totals.daysListened)} of {nf(s.totals.spanDays)} days</span
>
</dd>
</div>
</dl>
{/if}
<section class="mb-12" aria-labelledby="daily-heading">
<h2 id="daily-heading" class="mb-3 text-xl font-semibold tracking-tight">
hours per day <span class="text-sm font-normal text-[var(--site-muted)]"
>(last {s.daily.length} days)</span
<section class="mb-12" aria-labelledby="chart-heading">
<h2 id="chart-heading" class="mb-3 text-xl font-semibold tracking-tight">
hours per {unitName}
<span class="text-sm font-normal text-[var(--site-muted)]"
>({s.range === 'all' ? 'all time' : `last ${periodName}`})</span
>
</h2>
<div class="relative">
<div
class="flex h-32 items-end gap-px rounded-md border border-[var(--site-border)] p-2"
role="group"
aria-label="hours listened per day"
onpointerleave={(e) => {
if (e.pointerType === 'mouse') activeDay = null;
}}
>
{#each s.daily as d, i (d.day)}
<button
type="button"
bind:this={bars[i]}
class="group flex h-full min-w-0 flex-1 cursor-default items-end rounded-sm focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[var(--site-text)]"
tabindex={i === tabDay ? 0 : -1}
aria-label="{fullDayLabel(d.day)}: {exactHours(d.hours)}, {d.plays} plays"
onpointerenter={(e) => {
if (e.pointerType === 'mouse') activeDay = i;
}}
onpointerdown={(e) => {
wasOpen = e.pointerType !== 'mouse' && activeDay === i;
}}
onclick={() => {
activeDay = wasOpen ? null : i;
wasOpen = false;
}}
onfocus={() => {
focusDay = i;
activeDay = i;
}}
onblur={() => {
if (activeDay === i) activeDay = null;
}}
onkeydown={(e) => moveDay(e, i)}
>
<span
class="block w-full rounded-sm transition-colors {activeDay === i
? 'bg-[#1db954]'
: 'bg-[#1db954]/80 group-hover:bg-[#1db954]'}"
style="height: {Math.max(d.hours > 0 ? 3 : 0, (d.hours / maxHours) * 100)}%"
></span>
</button>
{/each}
</div>
{#if tipDay && activeDay !== null}
{@const pos = (activeDay + 0.5) / s.daily.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({tipShift(pos)})"
aria-hidden="true"
>
<span class="block font-semibold tabular-nums">{exactHours(tipDay.hours)}</span>
<span class="block text-xs text-[var(--site-muted)]"
>{fullDayLabel(tipDay.day)} · {tipDay.plays.toLocaleString('en-GB')}
{tipDay.plays === 1 ? 'play' : 'plays'}</span
>
</div>
{/if}
</div>
<div class="mt-1 flex justify-between text-xs text-[var(--site-muted)]">
<span>{dayLabel(s.daily[0]?.day ?? '')}</span><span>today</span>
</div>
<HoursChart
bars={hoursBars}
label="hours listened per {unitName}"
startLabel={s.chart[0] ? bucketShort(s.chart[0].bucket) : ''}
endLabel={s.unit === 'day' ? 'today' : s.unit === 'week' ? 'this week' : 'this month'}
/>
</section>
<div class="grid gap-10 lg:grid-cols-3">
{#each [{ id: 'artists', title: 'top artists', rows: s.topArtists.map( (a) => ({ ...a, sub: `${fmtHours(a.hours)} h` }) ) }, { id: 'tracks', title: 'top tracks', rows: s.topTracks.map( (t) => ({ ...t, sub: t.artists }) ) }, { id: 'albums', title: 'top albums', rows: s.topAlbums.map( (a) => ({ ...a, sub: a.artist }) ) }] as list (list.id)}
<section aria-labelledby="{list.id}-heading">
<h2 id="{list.id}-heading" class="mb-3 text-xl font-semibold tracking-tight">
{list.title}
</h2>
{#if list.rows.length === 0}
<p class="text-sm text-[var(--site-muted)]">nothing yet</p>
{:else}
<ol class="space-y-2">
{#each list.rows as row, i (i)}
<li class="flex items-center gap-3">
<span class="w-5 shrink-0 text-right text-sm text-[var(--site-muted)]">{i + 1}</span
>
{#if row.image}
<img
src={row.image}
alt=""
width="40"
height="40"
loading="lazy"
class="size-10 shrink-0 rounded object-cover {list.id === 'artists'
? 'rounded-full'
: ''}"
/>
<div class="grid gap-10 lg:grid-cols-2">
<section aria-labelledby="artists-heading">
<h2 id="artists-heading" class="mb-3 text-xl font-semibold tracking-tight">top artists</h2>
{#if s.topArtists.length === 0}
<p class="text-sm text-[var(--site-muted)]">nothing yet</p>
{:else}
<ol class="space-y-2">
{#each s.topArtists as row, i (i)}
<li class="flex items-center gap-3">
<span class="w-5 shrink-0 text-right text-sm text-[var(--site-muted)]">{i + 1}</span>
{#if row.image}
<img
src={row.image}
alt=""
width="40"
height="40"
loading="lazy"
class="size-10 shrink-0 rounded-full object-cover"
/>
{:else}
<span class="size-10 shrink-0 rounded-full bg-[var(--site-border)]"></span>
{/if}
<span class="min-w-0 flex-1">
{#if row.url}
<a
href={row.url}
rel="external"
class="block truncate font-medium hover:underline">{row.name}</a
>
{:else}
<span class="size-10 shrink-0 rounded bg-[var(--site-border)]"></span>
<span class="block truncate font-medium">{row.name}</span>
{/if}
<span class="min-w-0 flex-1">
{#if row.url}
<a
href={row.url}
rel="external"
class="block truncate font-medium hover:underline">{row.name}</a
>
{:else}
<span class="block truncate font-medium">{row.name}</span>
{/if}
<span class="block truncate text-sm text-[var(--site-muted)]">{row.sub}</span>
</span>
<span class="shrink-0 text-sm text-[var(--site-muted)]">{row.plays}</span>
<span class="block truncate text-sm text-[var(--site-muted)]"
>{fmtHours(row.hours)} h</span
>
</span>
{#if row.prevRank !== undefined}
<span
class="w-10 shrink-0 text-right text-xs {row.prevRank === null
? 'text-[#1db954]'
: row.prevRank > i + 1
? 'text-[#1db954]'
: row.prevRank < i + 1
? 'text-red-500'
: 'text-[var(--site-muted)]'}"
title={row.prevRank === null
? 'not in the previous period'
: `#${row.prevRank} in the previous period`}
>{row.prevRank === null
? 'new'
: row.prevRank === i + 1
? '='
: row.prevRank > i + 1
? `↑${row.prevRank - i - 1}`
: `↓${i + 1 - row.prevRank}`}</span
>
{/if}
<span class="w-12 shrink-0 text-right text-sm text-[var(--site-muted)]"
>{nf(row.plays)}</span
>
</li>
{/each}
</ol>
{/if}
</section>
<section aria-labelledby="tracks-heading">
<h2 id="tracks-heading" class="mb-3 text-xl font-semibold tracking-tight">top tracks</h2>
{#if s.topTracks.length === 0}
<p class="text-sm text-[var(--site-muted)]">nothing yet</p>
{:else}
<ol class="space-y-2">
{#each s.topTracks as row, i (i)}
<li class="flex items-center gap-3">
<span class="w-5 shrink-0 text-right text-sm text-[var(--site-muted)]">{i + 1}</span>
{#if row.image}
<img
src={row.image}
alt=""
width="40"
height="40"
loading="lazy"
class="size-10 shrink-0 rounded object-cover"
/>
{:else}
<span class="size-10 shrink-0 rounded bg-[var(--site-border)]"></span>
{/if}
<span class="min-w-0 flex-1">
{#if row.url}
<a
href={row.url}
rel="external"
class="block truncate font-medium hover:underline">{row.name}</a
>
{:else}
<span class="block truncate font-medium">{row.name}</span>
{/if}
<span class="block truncate text-sm text-[var(--site-muted)]">{row.artists}</span>
</span>
<span class="shrink-0 text-sm text-[var(--site-muted)]">{nf(row.plays)}</span>
</li>
{/each}
</ol>
{/if}
</section>
</div>
<section class="mt-12" aria-labelledby="albums-heading">
<h2 id="albums-heading" class="mb-3 text-xl font-semibold tracking-tight">top albums</h2>
{#if s.topAlbums.length === 0}
<p class="text-sm text-[var(--site-muted)]">nothing yet</p>
{:else}
<ol class="grid grid-cols-2 gap-4 sm:grid-cols-4 lg:grid-cols-6">
{#each s.topAlbums as album, i (i)}
<li class="min-w-0">
<a
href={album.url ?? undefined}
rel="external"
class="group block {album.url ? '' : 'pointer-events-none'}"
>
{#if album.image}
<img
src={album.image}
alt=""
width="300"
height="300"
loading="lazy"
class="aspect-square w-full rounded-md object-cover transition-transform group-hover:-translate-y-0.5"
/>
{:else}
<span
class="flex aspect-square w-full items-center justify-center rounded-md bg-[var(--site-border)]"
><Music size={24} aria-hidden="true" class="text-[var(--site-muted)]" /></span
>
{/if}
<span class="mt-2 block truncate text-sm font-medium group-hover:underline"
>{i + 1}. {album.name}</span
>
</a>
<span class="block truncate text-xs text-[var(--site-muted)]"
>{album.artist} · {nf(album.plays)} plays</span
>
</li>
{/each}
</ol>
{/if}
</section>
<section class="mt-12" aria-labelledby="genres-heading">
<h2 id="genres-heading" class="mb-1 text-xl font-semibold tracking-tight">genres</h2>
<p class="mb-4 text-xs text-[var(--site-muted)]">
From Last.fm artist tags; each artist's hours are split across its top genres.
{#if s.genres.coverage < 0.98}{Math.round(s.genres.coverage * 100)}% of listening time is
tagged so far.{/if}
</p>
{#if s.genres.top.length === 0}
<p class="text-sm text-[var(--site-muted)]">no genres yet</p>
{:else}
<div class="grid gap-8 lg:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]">
<ol class="space-y-1.5">
{#each s.genres.top.slice(0, 12) as g (g.tag)}
<li class="grid grid-cols-[8rem_minmax(0,1fr)_3.5rem] items-center gap-2 text-sm">
<span class="truncate">{g.tag}</span>
<span class="h-2 rounded-full bg-[var(--site-border)]">
<span
class="block h-2 rounded-full"
style="width: {(g.hours / maxGenre) * 100}%; background: {genreColor[g.tag] ??
OTHER_COLOR}"
></span>
</span>
<span class="text-right text-xs text-[var(--site-muted)] tabular-nums"
>{fmtHours(g.hours)} h</span
>
</li>
{/each}
</ol>
<div>
<h3 class="mb-2 text-sm font-medium">genres per {unitName}</h3>
<HoursChart
bars={genreBars}
label="genres per {unitName}"
baseColor={OTHER_COLOR}
startLabel={s.chart[0] ? bucketShort(s.chart[0].bucket) : ''}
endLabel={s.unit === 'day' ? 'today' : s.unit === 'week' ? 'this week' : 'this month'}
/>
<ul class="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-[var(--site-muted)]">
{#each s.genres.series as g (g)}
<li class="flex items-center gap-1">
<span class="size-2 rounded-full" style="background: {genreColor[g]}"></span>{g}
</li>
{/each}
<li class="flex items-center gap-1">
<span class="size-2 rounded-full" style="background: {OTHER_COLOR}"></span>other
</li>
</ul>
</div>
</div>
{/if}
</section>
<section class="mt-12" aria-labelledby="clock-heading">
<h2 id="clock-heading" class="mb-3 text-xl font-semibold tracking-tight">listening clock</h2>
<div class="overflow-x-auto">
<div
class="grid min-w-[36rem] grid-cols-[2.5rem_repeat(24,minmax(0,1fr))] gap-0.5 text-xs"
role="img"
aria-label="Listening time by weekday and hour. {clockText}"
onpointerleave={() => (clockHover = null)}
onpointerover={pickCell}
onpointerdown={pickCell}
>
{#each clockCells as row, d (d)}
<span class="self-center text-[var(--site-muted)]">{DAYS[d]}</span>
{#each row as cell, h (h)}
<span
class="aspect-square rounded-sm"
style="background: {cell.hours
? `color-mix(in srgb, #1db954 ${Math.round(15 + (cell.hours / maxCell) * 85)}%, transparent)`
: 'var(--site-border)'}"
title="{DAYS[d]} {h}:00 · {fmtHours(cell.hours)} h"
data-cell="{d},{h}"
></span>
{/each}
{/each}
<span></span>
{#each Array.from({ length: 24 }, (_, h) => h) as h (h)}
<span class="text-center text-[var(--site-muted)]">{h % 3 === 0 ? h : ''}</span>
{/each}
</div>
</div>
<p class="mt-2 text-sm text-[var(--site-muted)]" aria-live="polite">{clockText}</p>
</section>
<div class="mt-12 grid gap-10 lg:grid-cols-2">
{#if s.previous}
<section aria-labelledby="compare-heading">
<h2 id="compare-heading" class="mb-3 text-xl font-semibold tracking-tight">
vs the previous {periodName}
</h2>
<dl class="mb-4 grid grid-cols-2 gap-3 text-sm">
{#each [['plays', s.totals.plays, s.previous.plays], ['hours', s.totals.hours, s.previous.hours], ['artists', s.totals.artists, s.previous.artists], ['tracks', s.totals.tracks, s.previous.tracks]] as const as [name, cur, before] (name)}
<div class="rounded-md border border-[var(--site-border)] px-3 py-2">
<dt class="text-[var(--site-muted)]">{name}</dt>
<dd>
{name === 'hours' ? fmtHours(before) : nf(before)} → {name === 'hours'
? fmtHours(cur)
: nf(cur)}
<span class={changeClass(cur, before)}>{change(cur, before)}</span>
</dd>
</div>
{/each}
</dl>
{#if s.movers.length}
<h3 class="mb-2 text-sm font-medium">top movers</h3>
<ol class="space-y-1 text-sm">
{#each s.movers as m (m.id)}
<li class="flex gap-3">
<span class="min-w-0 flex-1 truncate">{m.name}</span>
<span class="text-[var(--site-muted)]"
>{m.prevRank ? `#${m.prevRank}` : 'new'} → #{m.rank}</span
>
<span class="w-16 text-right text-[#1db954]">+{nf(m.plays - m.prevPlays)}</span>
</li>
{/each}
</ol>
{/if}
</section>
{/each}
<section aria-labelledby="new-heading">
<h2 id="new-heading" class="mb-3 text-xl font-semibold tracking-tight">
new artists <span class="text-sm font-normal text-[var(--site-muted)]"
>({nf(s.newArtists.total)} first heard in the last {periodName})</span
>
</h2>
{#if s.newArtists.top.length === 0}
<p class="text-sm text-[var(--site-muted)]">no new artists</p>
{:else}
<ol class="space-y-1 text-sm">
{#each s.newArtists.top as a (a.name)}
<li class="flex gap-3">
{#if a.url}
<a href={a.url} rel="external" class="min-w-0 flex-1 truncate hover:underline"
>{a.name}</a
>
{:else}
<span class="min-w-0 flex-1 truncate">{a.name}</span>
{/if}
<span class="text-[var(--site-muted)]">since {shortDay(a.first)}</span>
<span class="w-16 text-right text-[var(--site-muted)]">{nf(a.plays)} plays</span>
</li>
{/each}
</ol>
{/if}
</section>
{:else}
<section aria-labelledby="discovered-heading">
<h2 id="discovered-heading" class="mb-3 text-xl font-semibold tracking-tight">
new artists per year
</h2>
<ol class="space-y-1.5">
{#each s.discoveredByYear as d (d.year)}
<li class="grid grid-cols-[3rem_minmax(0,1fr)_3.5rem] items-center gap-2 text-sm">
<span class="text-[var(--site-muted)]">{d.year}</span>
<span class="h-2 rounded-full bg-[var(--site-border)]">
<span
class="block h-2 rounded-full bg-[#1db954]"
style="width: {(d.artists / maxDiscovered) * 100}%"
></span>
</span>
<span class="text-right text-xs text-[var(--site-muted)] tabular-nums"
>{nf(d.artists)}</span
>
</li>
{/each}
</ol>
</section>
{/if}
{#if s.range === 'all' || !s.previous}
<section aria-labelledby="milestones-heading">
<h2 id="milestones-heading" class="mb-3 text-xl font-semibold tracking-tight">
milestones
</h2>
<ol class="space-y-1.5 text-sm">
{#each s.milestones as m (m.n)}
<li class="flex gap-3">
<span class="w-20 shrink-0 text-[var(--site-muted)] tabular-nums"
>{m.n === 1 ? 'first' : `#${nf(m.n)}`}</span
>
<span class="min-w-0 flex-1 truncate">
{#if m.url}<a href={m.url} rel="external" class="hover:underline">{m.track}</a
>{:else}{m.track}{/if}
<span class="text-[var(--site-muted)]">· {m.artist}</span>
</span>
<time datetime={m.at} class="shrink-0 text-[var(--site-muted)]"
>{date(m.at, { day: 'numeric', month: 'short', year: 'numeric' })}</time
>
</li>
{/each}
</ol>
</section>
{/if}
</div>
<section class="mt-12" aria-labelledby="recent-heading">
@@ -313,8 +611,8 @@
{/if}
{#if s.sources.length > 1}
<p class="mt-4 text-xs text-[var(--site-muted)]">
sources: {s.sources
.map((x) => `${sourceLabel[x.source] ?? x.source} ${x.plays.toLocaleString('en-GB')}`)
sources{s.range === 'all' ? '' : ` (last ${periodName})`}: {s.sources
.map((x) => `${sourceLabel[x.source] ?? x.source} ${nf(x.plays)}`)
.join(' · ')}
</p>
{/if}
@@ -335,4 +633,15 @@
font-size: 1.5rem;
font-weight: 600;
}
.stat .delta {
margin-left: 0.25rem;
font-size: 0.75rem;
font-weight: 500;
}
.stat .sub {
display: block;
font-size: 0.75rem;
font-weight: 400;
color: var(--site-muted);
}
</style>