music: hourly listening chart for the 7-day range (168 Amsterdam-time buckets, day labels on the axis)
Deploy production / deploy (push) Successful in 1m29s

This commit is contained in:
stan committed 2026-10-27 10:43:49 +00:00
1 parent 9b87aeb577
commit 42711253b7
3 files changed
+72 -13

No files matched your search

+23 -3
View File
@@ -6,6 +6,8 @@
title: string; title: string;
hours: number; hours: number;
plays?: 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. */ /** Stacked parts (bottom first); the rest of `hours` is drawn in the base colour. */
segments?: ChartSegment[]; segments?: ChartSegment[];
}; };
@@ -42,6 +44,11 @@
`${h.toLocaleString('en-GB', { minimumFractionDigits: 1, maximumFractionDigits: 1 })} h`; `${h.toLocaleString('en-GB', { minimumFractionDigits: 1, maximumFractionDigits: 1 })} h`;
const shift = (pos: number) => (pos < 0.15 ? '-10%' : pos > 0.85 ? '-90%' : '-50%'); const shift = (pos: number) => (pos < 0.15 ? '-10%' : pos > 0.85 ? '-90%' : '-50%');
const pct = (h: number) => (h / max) * 100; 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) { function move(event: KeyboardEvent, i: number) {
if (event.key === 'Escape') { if (event.key === 'Escape') {
@@ -64,7 +71,9 @@
<div class="relative"> <div class="relative">
<div <div
class="flex {height} items-end gap-px rounded-md border border-[var(--site-border)] p-2" class="flex {height} items-end {dense
? 'gap-0'
: 'gap-px'} rounded-md border border-[var(--site-border)] p-2"
role="group" role="group"
aria-label={label} aria-label={label}
onpointerleave={(e) => { onpointerleave={(e) => {
@@ -77,7 +86,10 @@
<button <button
type="button" type="button"
bind:this={buttons[i]} 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)]" class="group flex h-full min-w-0 flex-1 cursor-default flex-col-reverse rounded-sm {bar.mark &&
i > 0
? 'border-l border-[var(--site-border)]'
: ''} focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[var(--site-text)]"
tabindex={i === tabIndexAt ? 0 : -1} tabindex={i === tabIndexAt ? 0 : -1}
aria-label="{bar.title}: {exactHours(bar.hours)}{bar.plays != null aria-label="{bar.title}: {exactHours(bar.hours)}{bar.plays != null
? `, ${bar.plays} plays` ? `, ${bar.plays} plays`
@@ -151,7 +163,15 @@
</div> </div>
{/if} {/if}
</div> </div>
{#if startLabel || endLabel} {#if marks.length}
<div class="relative mx-2 mt-1 h-4 text-xs text-[var(--site-muted)]" aria-hidden="true">
{#each marks as m (m.key)}
<span class="absolute whitespace-nowrap" style="left: {(m.i / bars.length) * 100}%"
>{m.label}</span
>
{/each}
</div>
{:else if startLabel || endLabel}
<div class="mt-1 flex justify-between text-xs text-[var(--site-muted)]"> <div class="mt-1 flex justify-between text-xs text-[var(--site-muted)]">
<span>{startLabel}</span><span>{endLabel}</span> <span>{startLabel}</span><span>{endLabel}</span>
</div> </div>
+25 -2
View File
@@ -238,7 +238,8 @@ async function computeStats(range: Range) {
genres, genres,
tagged, tagged,
genreTimeline, genreTimeline,
sources sources,
hourly
] = await Promise.all([ ] = await Promise.all([
totalsOf(cur), totalsOf(cur),
prevFrom ? totalsOf(prev) : Promise.resolve(null), prevFrom ? totalsOf(prev) : Promise.resolve(null),
@@ -341,7 +342,21 @@ async function computeStats(range: Range) {
from bx join (${tagShare()}) tw using (artist_id) from bx join (${tagShare()}) tw using (artist_id)
where tw.tag in (select tag from top) group by 1, 2`, 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()} 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]; const t = totals[0];
@@ -434,6 +449,14 @@ async function computeStats(range: Range) {
plays: r.plays as number, plays: r.plays as number,
hours: hoursOf(r.ms) 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) => ({ clock: clock.map((r) => ({
dow: r.dow as number, dow: r.dow as number,
hour: r.hour as number, hour: r.hour as number,
+24 -8
View File
@@ -46,13 +46,29 @@
const changeClass = (now: number, before: number) => const changeClass = (now: number, before: number) =>
now >= before ? 'text-[#1db954]' : 'text-red-500'; 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<ChartBar[]>( const hoursBars = $derived<ChartBar[]>(
s.chart.map((b) => ({ s.hourly
key: b.bucket, ? s.hourly.map((b) => ({
title: bucketTitle(b.bucket), key: b.bucket,
hours: b.hours, title: hourTitle(b.bucket),
plays: b.plays 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( const genreColor = $derived(
Object.fromEntries(s.genres.series.map((g, i) => [g, GENRE_COLORS[i % GENRE_COLORS.length]])) Object.fromEntries(s.genres.series.map((g, i) => [g, GENRE_COLORS[i % GENRE_COLORS.length]]))
@@ -217,14 +233,14 @@
<section class="mb-12" aria-labelledby="chart-heading"> <section class="mb-12" aria-labelledby="chart-heading">
<h2 id="chart-heading" class="mb-3 text-xl font-semibold tracking-tight"> <h2 id="chart-heading" class="mb-3 text-xl font-semibold tracking-tight">
hours per {unitName} hours per {s.hourly ? 'hour' : unitName}
<span class="text-sm font-normal text-[var(--site-muted)]" <span class="text-sm font-normal text-[var(--site-muted)]"
>({s.range === 'all' ? 'all time' : `last ${periodName}`})</span >({s.range === 'all' ? 'all time' : `last ${periodName}`})</span
> >
</h2> </h2>
<HoursChart <HoursChart
bars={hoursBars} bars={hoursBars}
label="hours listened per {unitName}" label="hours listened per {s.hourly ? 'hour' : unitName}"
startLabel={s.chart[0] ? bucketShort(s.chart[0].bucket) : ''} startLabel={s.chart[0] ? bucketShort(s.chart[0].bucket) : ''}
endLabel={s.unit === 'day' ? 'today' : s.unit === 'week' ? 'this week' : 'this month'} endLabel={s.unit === 'day' ? 'today' : s.unit === 'week' ? 'this week' : 'this month'}
/> />