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,57 @@
CREATE TABLE "music"."artist_tag_fetch" (
"artist_id" integer PRIMARY KEY,
"status" text NOT NULL,
"fetched_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "music"."artist_tags" (
"artist_id" integer,
"tag" text,
"weight" smallint NOT NULL,
CONSTRAINT "artist_tags_pkey" PRIMARY KEY("artist_id","tag")
);
--> statement-breakpoint
CREATE INDEX "artist_tags_tag_idx" ON "music"."artist_tags" ("tag");--> statement-breakpoint
ALTER TABLE "music"."artist_tag_fetch" ADD CONSTRAINT "artist_tag_fetch_artist_id_artists_id_fkey" FOREIGN KEY ("artist_id") REFERENCES "music"."artists"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "music"."artist_tags" ADD CONSTRAINT "artist_tags_artist_id_artists_id_fkey" FOREIGN KEY ("artist_id") REFERENCES "music"."artists"("id") ON DELETE CASCADE;
--> statement-breakpoint
-- Hand-written below. music.play_facts: plays_deduped flattened for the stats page (artist,
-- album, listening ms, local day/hour/weekday in Europe/Amsterdam). The dedupe view costs
-- ~300 ms per scan on 180k plays; the stats run ~15 aggregates over this narrow copy
-- instead. Refreshed by the app (music.refresh_play_facts()) when new plays arrive.
CREATE MATERIALIZED VIEW "music"."play_facts" AS
SELECT p.id, p.track_id, t.artist_id, t.album_id, p.played_at, p.source,
coalesce(p.ms_played, t.duration_ms, 210000)::int AS ms,
(p.played_at AT TIME ZONE 'Europe/Amsterdam')::date AS local_day,
extract(hour FROM p.played_at AT TIME ZONE 'Europe/Amsterdam')::smallint AS hour,
extract(isodow FROM p.played_at AT TIME ZONE 'Europe/Amsterdam')::smallint AS dow
FROM "music"."plays_deduped" p
JOIN "music"."tracks" t ON t.id = p.track_id;
--> statement-breakpoint
CREATE UNIQUE INDEX "play_facts_id_key" ON "music"."play_facts" ("id");
--> statement-breakpoint
CREATE INDEX "play_facts_day_idx" ON "music"."play_facts" ("local_day");
--> statement-breakpoint
CREATE INDEX "play_facts_artist_idx" ON "music"."play_facts" ("artist_id", "played_at");
--> statement-breakpoint
CREATE INDEX "play_facts_played_idx" ON "music"."play_facts" ("played_at");
--> statement-breakpoint
-- The app role may refresh it (REFRESH needs the owner): a definer function, nothing else.
CREATE FUNCTION "music"."refresh_play_facts"() RETURNS void
LANGUAGE sql SECURITY DEFINER SET search_path = pg_catalog, pg_temp AS $$
REFRESH MATERIALIZED VIEW CONCURRENTLY music.play_facts;
$$;
--> statement-breakpoint
REVOKE ALL ON FUNCTION "music"."refresh_play_facts"() FROM PUBLIC;
--> statement-breakpoint
DO $$
BEGIN
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
GRANT SELECT ON music.play_facts TO personal_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON music.artist_tags, music.artist_tag_fetch TO personal_app;
GRANT EXECUTE ON FUNCTION music.refresh_play_facts() TO personal_app;
END IF;
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ro') THEN
GRANT SELECT ON music.play_facts, music.artist_tags, music.artist_tag_fetch TO personal_ro;
END IF;
END $$;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,85 @@
// Fetch Last.fm artist top tags into music.artist_tags (genres for /trackers/music).
//
// LASTFM_API_KEY=... bun scripts/music-fetch-tags.ts [--limit N] [--sleep 250] [--refetch-days 90]
//
// Most-played artists first (all time). Artists fetched within --refetch-days are skipped
// ('not_found' is retried after 30 days), so a re-run only picks up new artists. Tags are
// normalised and filtered (src/lib/music/tags.ts); the top 5 genres per artist are kept.
// Rate limit: one request per --sleep ms (Last.fm asks for at most 5/s), back-off on 429/5xx.
import { pickTags } from '../src/lib/music/tags';
import { arg, musicDb, sleep } from './music-db';
const apiKey = process.env.LASTFM_API_KEY;
if (!apiKey) throw new Error('Set LASTFM_API_KEY');
const limit = Number(arg('limit') ?? 100_000);
const pause = Number(arg('sleep') ?? 250);
const refetchDays = Number(arg('refetch-days') ?? 90);
type LastfmTags = { toptags?: { tag?: { name: string; count: number }[] }; error?: number };
async function topTags(name: string): Promise<LastfmTags> {
const url = `https://ws.audioscrobbler.com/2.0/?${new URLSearchParams({
method: 'artist.gettoptags',
artist: name,
autocorrect: '1',
api_key: apiKey!,
format: 'json'
})}`;
for (let attempt = 0; ; attempt++) {
const response = await fetch(url, { signal: AbortSignal.timeout(15_000) }).catch(() => null);
const json = response ? ((await response.json().catch(() => ({}))) as LastfmTags) : {};
const retry =
!response ||
response.status === 429 ||
response.status >= 500 ||
json.error === 29 ||
json.error === 8 ||
json.error === 11 ||
json.error === 16;
if (!retry || attempt >= 4) return json;
await sleep(2_000 * 2 ** attempt);
}
}
const sql = musicDb();
try {
const artists = await sql`
select a.id, a.name, count(*)::int as plays
from music.plays p join music.tracks t on t.id = p.track_id
join music.artists a on a.id = t.artist_id
left join music.artist_tag_fetch tf on tf.artist_id = a.id
where tf.artist_id is null
or (tf.status = 'ok' and tf.fetched_at < now() - make_interval(days => ${refetchDays}))
or tf.status like 'error%'
or (tf.status <> 'ok' and tf.fetched_at < now() - interval '30 days')
group by a.id order by plays desc, a.id limit ${limit}`;
console.log(`${artists.length} artists to fetch.`);
let tagged = 0;
for (const [i, artist] of artists.entries()) {
const json = await topTags(artist.name as string);
const tags = pickTags(json.toptags?.tag ?? []);
const status =
json.error === 6
? 'not_found'
: json.error
? `error ${json.error}`
: tags.length
? 'ok'
: 'no_tags';
await sql.begin(async (tx) => {
if (!json.error || json.error === 6) {
await tx`delete from music.artist_tags where artist_id = ${artist.id}`;
for (const t of tags)
await tx`insert into music.artist_tags (artist_id, tag, weight) values (${artist.id}, ${t.tag}, ${t.weight})`;
}
await tx`insert into music.artist_tag_fetch (artist_id, status) values (${artist.id}, ${status})
on conflict (artist_id) do update set status = excluded.status, fetched_at = now()`;
});
if (tags.length) tagged++;
if ((i + 1) % 250 === 0) console.log(` ${i + 1}/${artists.length} (${tagged} with genres)`);
await sleep(pause);
}
console.log(`Done: ${tagged} of ${artists.length} artists got genres.`);
} finally {
await sql.end();
}

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

View File

@@ -190,3 +190,28 @@ export const musicImportState = musicSchema.table('import_state', {
data: jsonb('data').notNull(),
updatedAt: timestamptz('updated_at').notNull().defaultNow()
});
/**
* Genres: Last.fm artist.getTopTags per artist, normalised and filtered for junk tags
* (scripts/music-fetch-tags.ts). Weight is Last.fm's tag count (100 = the artist's top tag).
*/
export const musicArtistTags = musicSchema.table(
'artist_tags',
{
artistId: integer('artist_id')
.notNull()
.references(() => musicArtists.id, { onDelete: 'cascade' }),
tag: text('tag').notNull(),
weight: smallint('weight').notNull()
},
(t) => [primaryKey({ columns: [t.artistId, t.tag] }), index('artist_tags_tag_idx').on(t.tag)]
);
/** When tags were last fetched per artist: 'ok', 'no_tags' or 'not_found' (retry later). */
export const musicArtistTagFetch = musicSchema.table('artist_tag_fetch', {
artistId: integer('artist_id')
.primaryKey()
.references(() => musicArtists.id, { onDelete: 'cascade' }),
status: text('status').notNull(),
fetchedAt: timestamptz('fetched_at').notNull().defaultNow()
});

View File

@@ -7,12 +7,14 @@ import type { LiveListening } from '#lib/music/now-playing.js';
// track length (the API only reports plays of 30 s or more, not how long).
const TZ = 'Europe/Amsterdam';
// Fragments are built per query (postgres.js queries are single-use objects).
const playMs = () => sql`coalesce(p.ms_played, t.duration_ms, 210000)`;
export const RANGES = { '7d': 7, '30d': 30, all: null } as const;
export const RANGES = { '7d': 7, '30d': 30, '1y': 365, all: null } as const;
export type Range = keyof typeof RANGES;
export const parseRange = (value: string | null): Range =>
value && value in RANGES ? (value as Range) : '30d';
/** Chart bucket per range: days for 7d/30d, weeks for a year, months for all time. */
export const BUCKETS = { '7d': 'day', '30d': 'day', '1y': 'week', all: 'month' } as const;
export type Bucket = (typeof BUCKETS)[Range];
export type Listening = {
nowPlaying: boolean;
@@ -108,98 +110,310 @@ export async function getLiveFromDb(): Promise<LiveListening | null> {
}
}
// The heavy aggregates read music.play_facts (a materialized copy of plays_deduped with
// artist, album, listening ms and local day/hour/weekday). The app refreshes it when new
// plays arrive (a definer function; REFRESH needs the owner) and caches results per range
// until the next refresh or for five minutes. Recent plays always read the live view.
const CACHE_MS = 5 * 60_000;
const MILESTONES = [
1, 1_000, 10_000, 25_000, 50_000, 100_000, 150_000, 200_000, 250_000, 300_000, 400_000, 500_000
];
let factsVersion = 0;
let factsWatermark: number | null = null;
let refreshing: Promise<void> | null = null;
const statsCache = new Map<Range, { at: number; version: number; data: Promise<MusicStats> }>();
/** Re-materialize music.play_facts (single flight). */
export function refreshPlayFacts(): Promise<void> {
refreshing ??= (async () => {
const [row] = await sql`select coalesce(max(id), 0)::int as id from music.plays`;
await sql`select music.refresh_play_facts()`;
factsWatermark = row.id as number;
factsVersion++;
})()
.catch((e) =>
console.error(`[music] play_facts refresh failed: ${e instanceof Error ? e.message : e}`)
)
.finally(() => (refreshing = null));
return refreshing;
}
/** Kick off a refresh when plays were added since the last one (never blocks the page). */
async function checkFacts() {
const [row] = await sql`select coalesce(max(id), 0)::int as id from music.plays`;
if (row.id !== factsWatermark) void refreshPlayFacts();
}
const DAY_MS = 86_400_000;
const isoDay = (ms: number) => new Date(ms).toISOString().slice(0, 10);
const dayMs = (day: string) => Date.parse(`${day}T00:00:00Z`);
/** Today's date in Amsterdam, YYYY-MM-DD. */
const localToday = () =>
new Intl.DateTimeFormat('en-CA', {
timeZone: TZ,
year: 'numeric',
month: '2-digit',
day: '2-digit'
}).format(new Date());
const hoursOf = (v: unknown) => Number(v ?? 0) / 3_600_000;
export async function getMusicStats(range: Range) {
const [, stats, recent] = await Promise.all([
checkFacts().catch(() => {}),
cachedStats(range),
getRecentPlays()
]);
return { ...stats, recent };
}
function cachedStats(range: Range): Promise<MusicStats> {
const hit = statsCache.get(range);
if (hit && hit.version === factsVersion && Date.now() - hit.at < CACHE_MS) return hit.data;
const data = computeStats(range);
statsCache.set(range, { at: Date.now(), version: factsVersion, data });
data.catch(() => statsCache.delete(range));
return data;
}
/** The latest plays, live (not cached, not from play_facts). */
async function getRecentPlays() {
const rows = await sql`
select p.id, p.played_at, p.source, t.name as track, t.spotify_id, ${artistsOf()} as artists,
coalesce(al.image_url_small, al.image_url) as image
from music.plays_deduped p
join music.tracks t on t.id = p.track_id
left join music.artists a on a.id = t.artist_id
left join music.albums al on al.id = t.album_id
order by p.played_at desc limit 30`;
return rows.map((r) => ({
id: r.id as number,
at: new Date(r.played_at).toISOString(),
source: r.source as string,
track: r.track as string,
artists: r.artists as string,
image: (r.image as string) ?? null,
url: r.spotify_id ? `https://open.spotify.com/track/${r.spotify_id}` : null
}));
}
async function computeStats(range: Range) {
const days = RANGES[range];
const since = (days ? new Date(Date.now() - days * 86_400_000) : new Date(0)).toISOString();
const chartDays = days ?? 90;
const [totals, artists, tracks, albums, daily, streak, recent, sources] = await Promise.all([
const unit: Bucket = BUCKETS[range];
const today = localToday();
const from = days ? isoDay(dayMs(today) - (days - 1) * DAY_MS) : null;
const prevFrom = days && from ? isoDay(dayMs(from) - days * DAY_MS) : null;
// Fragments are single-use: build one per query.
const cur = () => (from ? sql`f.local_day >= ${from}::date` : sql`true`);
const prev = () => sql`f.local_day >= ${prevFrom}::date and f.local_day < ${from}::date`;
const step = `1 ${unit}`;
const tagShare =
() => sql`select artist_id, tag, weight::float8 / sum(weight) over (partition by artist_id) as share
from music.artist_tags`;
const totalsOf = (where: typeof cur) => sql`
select count(*)::int as plays, coalesce(sum(f.ms), 0)::float8 as ms,
count(distinct f.track_id)::int as tracks, count(distinct f.artist_id)::int as artists,
count(distinct f.album_id)::int as albums, count(distinct f.local_day)::int as days,
min(f.local_day)::text as first
from music.play_facts f where ${where()}`;
const ranksOf = (where: typeof cur, limit: number) => sql`
select f.artist_id as id, count(*)::int as plays,
(row_number() over (order by count(*) desc, f.artist_id))::int as rank
from music.play_facts f where ${where()} and f.artist_id is not null
group by f.artist_id order by plays desc, f.artist_id limit ${limit}`;
const [
totals,
prevTotals,
artists,
curRanks,
prevRanks,
tracks,
albums,
chart,
clock,
newArtists,
discovered,
streak,
milestones,
genres,
tagged,
genreTimeline,
sources
] = await Promise.all([
totalsOf(cur),
prevFrom ? totalsOf(prev) : Promise.resolve(null),
sql`
select count(*)::int as plays,
coalesce(sum(${playMs()}), 0)::float8 / 3600000 as hours,
count(distinct p.track_id)::int as tracks,
count(distinct t.artist_id)::int as artists,
min(p.played_at) as first
from music.plays_deduped p join music.tracks t on t.id = p.track_id
where p.played_at >= ${since}::timestamptz`,
sql`
select a.id, a.name, a.spotify_id, count(*)::int as plays,
coalesce(sum(${playMs()}), 0)::float8 / 3600000 as hours,
select a.id, a.name, a.spotify_id, x.plays, x.ms,
coalesce(a.image_url, (select coalesce(al.image_url_small, al.image_url) from music.albums al
where al.artist_id = a.id and al.image_url is not null
order by al.spotify_id is null, al.id desc limit 1)) as image
from music.plays_deduped p
join music.tracks t on t.id = p.track_id
join music.artists a on a.id = t.artist_id
where p.played_at >= ${since}::timestamptz
group by a.id order by plays desc, a.name limit 10`,
from (select f.artist_id, count(*)::int as plays, sum(f.ms)::float8 as ms
from music.play_facts f where ${cur()} and f.artist_id is not null
group by f.artist_id order by plays desc limit 10) x
join music.artists a on a.id = x.artist_id
order by x.plays desc, a.name`,
prevFrom ? ranksOf(cur, 100) : Promise.resolve([]),
prevFrom ? ranksOf(prev, 1000) : Promise.resolve([]),
sql`
select t.id, t.name, t.spotify_id, ${artistsOf()} as artists, count(*)::int as plays,
select t.id, t.name, t.spotify_id, ${artistsOf()} as artists, x.plays,
coalesce(al.image_url_small, al.image_url) as image
from music.plays_deduped p
join music.tracks t on t.id = p.track_id
from (select f.track_id, count(*)::int as plays from music.play_facts f where ${cur()}
group by f.track_id order by plays desc limit 10) x
join music.tracks t on t.id = x.track_id
left join music.artists a on a.id = t.artist_id
left join music.albums al on al.id = t.album_id
where p.played_at >= ${since}::timestamptz
group by t.id, a.name, al.id order by plays desc, t.name limit 10`,
order by x.plays desc, t.name`,
sql`
select al.id, al.name, al.spotify_id, a.name as artist, count(*)::int as plays,
coalesce(al.image_url_small, al.image_url) as image
from music.plays_deduped p
join music.tracks t on t.id = p.track_id
join music.albums al on al.id = t.album_id
select al.id, al.name, al.spotify_id, a.name as artist, x.plays, x.ms,
coalesce(al.image_url, al.image_url_small) as image
from (select f.album_id, count(*)::int as plays, sum(f.ms)::float8 as ms
from music.play_facts f where ${cur()} and f.album_id is not null
group by f.album_id order by plays desc limit 12) x
join music.albums al on al.id = x.album_id
left join music.artists a on a.id = al.artist_id
where p.played_at >= ${since}::timestamptz
group by al.id, a.name order by plays desc, al.name limit 10`,
order by x.plays desc, al.name`,
sql`
with days as (
select generate_series((now() at time zone ${TZ})::date - ${chartDays - 1}::int,
(now() at time zone ${TZ})::date, interval '1 day')::date as day)
select d.day::text as day, count(p.id)::int as plays,
coalesce(sum(${playMs()}), 0)::float8 / 3600000 as hours
from days d
left join music.plays_deduped p
on p.played_at >= now() - make_interval(days => ${chartDays + 1})
and (p.played_at at time zone ${TZ})::date = d.day
left join music.tracks t on t.id = p.track_id
group by d.day order by d.day`,
with b as (
select generate_series(
date_trunc(${unit}, coalesce(${from}::date, (select min(local_day) from music.play_facts), ${today}::date)),
date_trunc(${unit}, ${today}::date), ${step}::interval)::date as bucket)
select b.bucket::text as bucket, coalesce(x.plays, 0)::int as plays, coalesce(x.ms, 0)::float8 as ms
from b left join (
select date_trunc(${unit}, f.local_day)::date as bucket, count(*) as plays, sum(f.ms) as ms
from music.play_facts f where ${cur()} group by 1) x using (bucket)
order by b.bucket`,
sql`
with d as (select distinct (played_at at time zone ${TZ})::date as day from music.plays_deduped),
select f.dow, f.hour, count(*)::int as plays, sum(f.ms)::float8 as ms
from music.play_facts f where ${cur()} group by 1, 2`,
from
? sql`
with firsts as (
select artist_id, min(played_at) as first from music.play_facts
where artist_id is not null group by artist_id having min(local_day) >= ${from}::date)
select a.name, a.spotify_id, fi.first, count(*)::int as plays, (count(*) over ())::int as total
from firsts fi
join music.play_facts f on f.artist_id = fi.artist_id
join music.artists a on a.id = fi.artist_id
group by a.id, fi.first order by plays desc, a.name limit 10`
: Promise.resolve([]),
sql`
select extract(year from first)::int as year, count(*)::int as artists
from (select min(local_day) as first from music.play_facts where artist_id is not null
group by artist_id) x group by 1 order by 1`,
sql`
with d as (select distinct local_day as day from music.play_facts),
g as (select day, day - (row_number() over (order by day))::int as grp from d),
s as (select min(day) as start_day, max(day) as end_day, count(*)::int as len from g group by grp)
select
coalesce((select len from s where end_day >= (now() at time zone ${TZ})::date - 1
coalesce((select len from s where end_day >= ${today}::date - 1
order by end_day desc limit 1), 0) as current,
coalesce((select len from s order by len desc, end_day desc limit 1), 0) as longest,
(select start_day::text from s order by len desc, end_day desc limit 1) as longest_start,
(select end_day::text from s order by len desc, end_day desc limit 1) as longest_end,
(select count(*)::int from d) as days`,
sql`
select p.id, p.played_at, p.source, t.name as track, t.spotify_id, ${artistsOf()} as artists,
coalesce(al.image_url_small, al.image_url) as image
from music.plays_deduped p
join music.tracks t on t.id = p.track_id
select x.n::int as n, x.played_at, t.name as track, t.spotify_id, a.name as artist
from (select track_id, played_at, row_number() over (order by played_at, id) as n
from music.play_facts) x
join music.tracks t on t.id = x.track_id
left join music.artists a on a.id = t.artist_id
left join music.albums al on al.id = t.album_id
order by p.played_at desc limit 50`,
sql`select source, count(*)::int as plays from music.plays_deduped group by source order by plays desc`
where x.n = any(${MILESTONES}::int[]) order by x.n`,
sql`
with ah as (select f.artist_id, sum(f.ms)::float8 as ms from music.play_facts f
where ${cur()} and f.artist_id is not null group by 1),
tw as (${tagShare()})
select tw.tag, sum(ah.ms * tw.share) as ms, count(*)::int as artists
from ah join tw using (artist_id) group by tw.tag order by ms desc limit 20`,
sql`
select coalesce(sum(f.ms) filter (where exists (
select 1 from music.artist_tags at where at.artist_id = f.artist_id)), 0)::float8 as tagged,
coalesce(sum(f.ms), 0)::float8 as total
from music.play_facts f where ${cur()}`,
sql`
with ah as (select f.artist_id, sum(f.ms)::float8 as ms from music.play_facts f
where ${cur()} and f.artist_id is not null group by 1),
tw as (${tagShare()}),
top as (select tw.tag from ah join tw using (artist_id) group by tw.tag
order by sum(ah.ms * tw.share) desc limit 6),
bx as (select date_trunc(${unit}, f.local_day)::date as bucket, f.artist_id, sum(f.ms)::float8 as ms
from music.play_facts f where ${cur()} and f.artist_id is not null group by 1, 2)
select bx.bucket::text as bucket, tw.tag, sum(bx.ms * tw.share) as ms
from bx join (${tagShare()}) tw using (artist_id)
where tw.tag in (select tag from top) group by 1, 2`,
sql`select f.source, count(*)::int as plays from music.play_facts f where ${cur()}
group by f.source order by plays desc`
]);
const t = totals[0];
const pt = prevTotals?.[0];
const spanDays =
days ?? (t.first ? Math.round((dayMs(today) - dayMs(t.first as string)) / DAY_MS) + 1 : 1);
const prevRank = new Map(prevRanks.map((r) => [r.id as number, r]));
const curRank = new Map(curRanks.map((r) => [r.id as number, r]));
const names = new Map(artists.map((a) => [a.id as number, a.name as string]));
const topGenres = genres.slice(0, 6).map((g) => g.tag as string);
const timeline = new Map<string, Record<string, number>>();
for (const r of genreTimeline) {
const row = timeline.get(r.bucket as string) ?? {};
row[r.tag as string] = hoursOf(r.ms);
timeline.set(r.bucket as string, row);
}
// Movers: artists with the biggest gain in plays over the previous period.
const movers = curRanks
.map((r) => {
const p = prevRank.get(r.id as number);
return {
id: r.id as number,
rank: r.rank as number,
prevRank: (p?.rank as number) ?? null,
plays: r.plays as number,
prevPlays: (p?.plays as number) ?? 0
};
})
.filter((m) => m.plays > m.prevPlays)
.sort((a, b) => b.plays - b.prevPlays - (a.plays - a.prevPlays))
.slice(0, 5);
const moverIds = movers.map((m) => m.id).filter((id) => !names.has(id));
if (moverIds.length)
for (const r of await sql`select id, name from music.artists where id = any(${moverIds}::int[])`)
names.set(r.id as number, r.name as string);
return {
range,
unit,
from,
today,
totals: {
plays: t.plays as number,
hours: Number(t.hours),
hours: hoursOf(t.ms),
tracks: t.tracks as number,
artists: t.artists as number,
first: t.first ? new Date(t.first).toISOString() : null
albums: t.albums as number,
daysListened: t.days as number,
spanDays,
first: (t.first as string) ?? null
},
topArtists: artists.map((r) => ({
name: r.name as string,
plays: r.plays as number,
hours: Number(r.hours),
image: (r.image as string) ?? null,
url: r.spotify_id ? `https://open.spotify.com/artist/${r.spotify_id}` : null
})),
previous: pt
? {
plays: pt.plays as number,
hours: hoursOf(pt.ms),
artists: pt.artists as number,
tracks: pt.tracks as number
}
: null,
topArtists: artists.map((r) => {
const p = prevRank.get(r.id as number);
return {
name: r.name as string,
plays: r.plays as number,
hours: hoursOf(r.ms),
image: (r.image as string) ?? null,
url: r.spotify_id ? `https://open.spotify.com/artist/${r.spotify_id}` : null,
rank: (curRank.get(r.id as number)?.rank as number) ?? null,
prevRank: prevFrom ? ((p?.rank as number) ?? null) : undefined
};
}),
movers: movers.map((m) => ({ ...m, name: names.get(m.id) ?? '' })),
topTracks: tracks.map((r) => ({
name: r.name as string,
artists: r.artists as string,
@@ -211,14 +425,48 @@ export async function getMusicStats(range: Range) {
name: r.name as string,
artist: (r.artist as string) ?? '',
plays: r.plays as number,
hours: hoursOf(r.ms),
image: (r.image as string) ?? null,
url: r.spotify_id ? `https://open.spotify.com/album/${r.spotify_id}` : null
})),
daily: daily.map((r) => ({
day: r.day as string,
chart: chart.map((r) => ({
bucket: r.bucket as string,
plays: r.plays as number,
hours: Number(r.hours)
hours: hoursOf(r.ms)
})),
clock: clock.map((r) => ({
dow: r.dow as number,
hour: r.hour as number,
plays: r.plays as number,
hours: hoursOf(r.ms)
})),
newArtists: {
total: (newArtists[0]?.total as number) ?? 0,
top: newArtists.map((r) => ({
name: r.name as string,
plays: r.plays as number,
first: new Date(r.first).toISOString(),
url: r.spotify_id ? `https://open.spotify.com/artist/${r.spotify_id}` : null
}))
},
discoveredByYear: discovered.map((r) => ({
year: r.year as number,
artists: r.artists as number
})),
genres: {
top: genres.map((g) => ({
tag: g.tag as string,
hours: hoursOf(g.ms),
artists: g.artists as number
})),
coverage: Number(tagged[0].total) ? Number(tagged[0].tagged) / Number(tagged[0].total) : 0,
series: topGenres,
timeline: chart.map((r) => ({
bucket: r.bucket as string,
hours: hoursOf(r.ms),
tags: timeline.get(r.bucket as string) ?? {}
}))
},
streak: {
current: Number(streak[0].current),
longest: Number(streak[0].longest),
@@ -226,19 +474,19 @@ export async function getMusicStats(range: Range) {
longestEnd: (streak[0].longest_end as string) ?? null,
days: Number(streak[0].days)
},
recent: recent.map((r) => ({
id: r.id as number,
milestones: milestones.map((r) => ({
n: r.n as number,
at: new Date(r.played_at).toISOString(),
source: r.source as string,
track: r.track as string,
artists: r.artists as string,
image: (r.image as string) ?? null,
artist: (r.artist as string) ?? '',
url: r.spotify_id ? `https://open.spotify.com/track/${r.spotify_id}` : null
})),
sources: sources.map((r) => ({ source: r.source as string, plays: r.plays as number }))
};
}
export type MusicStats = Awaited<ReturnType<typeof computeStats>>;
/** Owner-only connection status for /trackers/music. Never includes tokens. */
export async function getSpotifyConnection() {
const [row] = await sql`

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>