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;
|
||||
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 @@
|
||||
|
||||
<div class="relative">
|
||||
<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"
|
||||
aria-label={label}
|
||||
onpointerleave={(e) => {
|
||||
@@ -77,7 +86,10 @@
|
||||
<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)]"
|
||||
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}
|
||||
aria-label="{bar.title}: {exactHours(bar.hours)}{bar.plays != null
|
||||
? `, ${bar.plays} plays`
|
||||
@@ -151,7 +163,15 @@
|
||||
</div>
|
||||
{/if}
|
||||
</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)]">
|
||||
<span>{startLabel}</span><span>{endLabel}</span>
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -46,8 +46,24 @@
|
||||
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<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,
|
||||
title: bucketTitle(b.bucket),
|
||||
hours: b.hours,
|
||||
@@ -217,14 +233,14 @@
|
||||
|
||||
<section class="mb-12" aria-labelledby="chart-heading">
|
||||
<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)]"
|
||||
>({s.range === 'all' ? 'all time' : `last ${periodName}`})</span
|
||||
>
|
||||
</h2>
|
||||
<HoursChart
|
||||
bars={hoursBars}
|
||||
label="hours listened per {unitName}"
|
||||
label="hours listened per {s.hourly ? 'hour' : unitName}"
|
||||
startLabel={s.chart[0] ? bucketShort(s.chart[0].bucket) : ''}
|
||||
endLabel={s.unit === 'day' ? 'today' : s.unit === 'week' ? 'this week' : 'this month'}
|
||||
/>
|
||||
|
||||
Reference in new issue
Block a user