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
Deploy production / deploy (push) Successful in 1m29s
This commit is contained in:
1 parent
9b87aeb577
commit
42711253b7
3 files changed
+67
-8
No files matched your search
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -46,8 +46,24 @@
|
|||||||
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
|
||||||
|
? 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,
|
key: b.bucket,
|
||||||
title: bucketTitle(b.bucket),
|
title: bucketTitle(b.bucket),
|
||||||
hours: b.hours,
|
hours: b.hours,
|
||||||
@@ -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'}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in new issue
Block a user