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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user