From ee20b9809169f58db504f404d6bc3a7d7e2a6ebb Mon Sep 17 00:00:00 2001 From: Ewan Croft Date: Thu, 13 Aug 2026 15:38:42 +0100 Subject: [PATCH 1/2] feat(tourmaline): expose monthlyArtistPlays on ListenerProfile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The aggregator already computes per-month artist play counts internally (AggregatedData.monthlyArtistPlays), but nothing surfaced it on the client-facing ListenerProfile. Needed as the data source for an upcoming bar-chart-race visualization — serialized as sorted tuples rather than a Map since it needs to round-trip through component props/state cleanly. --- packages/tourmaline/src/lib/client/load-profile.ts | 3 +++ packages/tourmaline/src/lib/types.ts | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/packages/tourmaline/src/lib/client/load-profile.ts b/packages/tourmaline/src/lib/client/load-profile.ts index 81453b3..9747012 100644 --- a/packages/tourmaline/src/lib/client/load-profile.ts +++ b/packages/tourmaline/src/lib/client/load-profile.ts @@ -159,6 +159,9 @@ export function computeProfile( albumMilestones: data.albumMilestones, longestNotListenedGap: data.longestNotListenedGap, recommendations: buildRecommendations(data.topArtists, data.allArtists, artistInfos), + monthlyArtistPlays: [...data.monthlyArtistPlays.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([month, plays]) => [month, [...plays.entries()]]), }; const sessions = deriveSessions(filtered); diff --git a/packages/tourmaline/src/lib/types.ts b/packages/tourmaline/src/lib/types.ts index dd4cf32..90d7c46 100644 --- a/packages/tourmaline/src/lib/types.ts +++ b/packages/tourmaline/src/lib/types.ts @@ -137,6 +137,12 @@ export interface ListenerProfile { albumMilestones: Milestone[]; longestNotListenedGap: Gap | null; recommendations: Recommendation[]; + /** + * Chronologically ordered per-month artist play counts, serialized as + * tuples (Maps don't survive structured cloning the way we need for + * $state). Used by the bar-chart-race visualization. + */ + monthlyArtistPlays: Array<[month: string, plays: Array<[artist: string, count: number]>]>; } export interface UnusualMonth { -- 2.51.2 From 1d6da7da98af94ea67b77875382e26f193672e89 Mon Sep 17 00:00:00 2001 From: Ewan Croft Date: Thu, 13 Aug 2026 15:38:49 +0100 Subject: [PATCH 2/2] feat(tourmaline): add animated bar-chart race for top artists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Precomputes one frame per month up front (running cumulative counts, snapshotted to the top 15 artists), then drives a horizontal Chart.js bar chart through the frames on a setInterval, relying on Chart.js's built-in animation to tween between frames — the standard bar-chart- race technique, no extra library. Playback (play/pause, scrubber, 0.5x/1x/2x speed) is driven by a single $effect keyed on `playing`/`speed`: the effect's cleanup clears the interval on every re-run, so pausing, changing speed, or unmounting can never leak a timer (same discipline as StoryRecap.svelte's resetAutoAdvance). Playback stops (without looping) at the last frame and the play button re-enables once the user scrubs back. Wired into the catalogue tab above the existing Top Artists list. --- .../src/routes/profile/[did]/+page.svelte | 6 + .../src/routes/profile/[did]/RaceChart.svelte | 227 ++++++++++++++++++ 2 files changed, 233 insertions(+) create mode 100644 packages/tourmaline/src/routes/profile/[did]/RaceChart.svelte diff --git a/packages/tourmaline/src/routes/profile/[did]/+page.svelte b/packages/tourmaline/src/routes/profile/[did]/+page.svelte index c3e6515..bdb5660 100644 --- a/packages/tourmaline/src/routes/profile/[did]/+page.svelte +++ b/packages/tourmaline/src/routes/profile/[did]/+page.svelte @@ -40,6 +40,7 @@ import CatalogueTab from './CatalogueTab.svelte'; import PunchcardHeatmap from './PunchcardHeatmap.svelte'; import EddingtonChart from './EddingtonChart.svelte'; + import RaceChart from './RaceChart.svelte'; import Recommendations from './Recommendations.svelte'; import TrackPreview from '$lib/components/TrackPreview.svelte'; import ListeningContext from './ListeningContext.svelte'; @@ -488,6 +489,11 @@ {:else if activeTab === 'catalogue'} + +
+ +
+
diff --git a/packages/tourmaline/src/routes/profile/[did]/RaceChart.svelte b/packages/tourmaline/src/routes/profile/[did]/RaceChart.svelte new file mode 100644 index 0000000..cfb53ce --- /dev/null +++ b/packages/tourmaline/src/routes/profile/[did]/RaceChart.svelte @@ -0,0 +1,227 @@ + + +{#if frames.length > 0} +
+
+

+ + Top Artists Race +

+ + {formatMonth(currentFrame?.month ?? '')} + +
+ +
+ +
+ +
+
+ + + + + + {frameIndex + 1}/{frames.length} + +
+ +
+ Speed + {#each SPEEDS as s (s)} + + {/each} +
+
+
+{/if} -- 2.51.2