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