From 42711253b78df77a4ba57782e8d59f0930162f76 Mon Sep 17 00:00:00 2001 From: Stan Runge Date: Tue, 27 Oct 2026 10:43:49 +0000 Subject: [PATCH] music: hourly listening chart for the 7-day range (168 Amsterdam-time buckets, day labels on the axis) --- src/lib/music/HoursChart.svelte | 26 ++++++++++++++++++--- src/lib/server/music/stats.ts | 27 ++++++++++++++++++++-- src/routes/trackers/music/+page.svelte | 32 +++++++++++++++++++------- 3 files changed, 72 insertions(+), 13 deletions(-) diff --git a/src/lib/music/HoursChart.svelte b/src/lib/music/HoursChart.svelte index de34185..234cb4d 100644 --- a/src/lib/music/HoursChart.svelte +++ b/src/lib/music/HoursChart.svelte @@ -6,6 +6,8 @@ title: string; hours: number; plays?: number; + /** Axis label at this bar (e.g. the day on an hourly chart), drawn with a faint divider. */ + mark?: string; /** Stacked parts (bottom first); the rest of `hours` is drawn in the base colour. */ segments?: ChartSegment[]; }; @@ -42,6 +44,11 @@ `${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; + // Dense charts (e.g. 168 hourly bars) drop the gap so bars stay visible on phones. + const dense = $derived(bars.length > 60); + const marks = $derived( + bars.flatMap((b, i) => (b.mark ? [{ i, label: b.mark, key: b.key }] : [])) + ); function move(event: KeyboardEvent, i: number) { if (event.key === 'Escape') { @@ -64,7 +71,9 @@
{ @@ -77,7 +86,10 @@
+{:else if startLabel || endLabel}
{startLabel}{endLabel}
diff --git a/src/lib/server/music/stats.ts b/src/lib/server/music/stats.ts index 0f059d5..0776716 100644 --- a/src/lib/server/music/stats.ts +++ b/src/lib/server/music/stats.ts @@ -238,7 +238,8 @@ async function computeStats(range: Range) { genres, tagged, genreTimeline, - sources + sources, + hourly ] = await Promise.all([ totalsOf(cur), prevFrom ? totalsOf(prev) : Promise.resolve(null), @@ -341,7 +342,21 @@ async function computeStats(range: Range) { 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` + group by f.source order by plays desc`, + // 7 days only: one bucket per local (Amsterdam wall-clock) hour, empty hours as 0, so + // always 7 × 24 = 168 points (a DST fall-back hour folds into its wall-clock bucket). + range === '7d' + ? sql` + with h as ( + select generate_series(${from}::date::timestamp, ${today}::date::timestamp + interval '23 hours', + interval '1 hour') as bucket) + select to_char(h.bucket, 'YYYY-MM-DD"T"HH24') as bucket, + coalesce(x.plays, 0)::int as plays, coalesce(x.ms, 0)::float8 as ms + from h left join ( + select f.local_day + make_interval(hours => f.hour) 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 h.bucket` + : Promise.resolve(null) ]); const t = totals[0]; @@ -434,6 +449,14 @@ async function computeStats(range: Range) { plays: r.plays as number, hours: hoursOf(r.ms) })), + /** 7d only: 168 hourly buckets keyed `YYYY-MM-DDTHH` (Amsterdam time); null otherwise. */ + hourly: hourly + ? hourly.map((r) => ({ + bucket: r.bucket as string, + plays: r.plays as number, + hours: hoursOf(r.ms) + })) + : null, clock: clock.map((r) => ({ dow: r.dow as number, hour: r.hour as number, diff --git a/src/routes/trackers/music/+page.svelte b/src/routes/trackers/music/+page.svelte index c9f48ee..2c88602 100644 --- a/src/routes/trackers/music/+page.svelte +++ b/src/routes/trackers/music/+page.svelte @@ -46,13 +46,29 @@ const changeClass = (now: number, before: number) => now >= before ? 'text-[#1db954]' : 'text-red-500'; + // 7 days: one bar per hour (bucket `YYYY-MM-DDTHH`, Amsterdam time), day names on the axis. + const hourTitle = (bucket: string) => { + const h = Number(bucket.slice(11, 13)); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${date(bucket, { weekday: 'short', day: 'numeric', month: 'short' })}, ${pad(h)}:00–${pad((h + 1) % 24)}:00`; + }; const hoursBars = $derived( - s.chart.map((b) => ({ - key: b.bucket, - title: bucketTitle(b.bucket), - hours: b.hours, - plays: b.plays - })) + s.hourly + ? s.hourly.map((b) => ({ + key: b.bucket, + title: hourTitle(b.bucket), + hours: b.hours, + plays: b.plays, + mark: b.bucket.endsWith('T00') + ? date(b.bucket, { weekday: 'short', day: 'numeric' }) + : undefined + })) + : 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]])) @@ -217,14 +233,14 @@

- hours per {unitName} + hours per {s.hourly ? 'hour' : unitName} ({s.range === 'all' ? 'all time' : `last ${periodName}`})