diff --git a/packages/tourmaline/src/lib/analysis/aggregator.ts b/packages/tourmaline/src/lib/analysis/aggregator.ts index afb1dfd..47af59c 100644 --- a/packages/tourmaline/src/lib/analysis/aggregator.ts +++ b/packages/tourmaline/src/lib/analysis/aggregator.ts @@ -30,6 +30,8 @@ export interface AggregatedData { weeklyScrobbles: Map; /** Sorted per-artist timestamp arrays (ms since epoch) for delta analysis */ artistTimestamps: Map; + /** artist → count of distinct ISO weeks played in. */ + artistWeeksActive: Map; // ── Derived stats computed in snapshot() ────────────────────────────── eddingtonNumber: number; daysSinceFirstScrobble: number; @@ -123,6 +125,8 @@ export class Aggregator { private albumFirstListen = new Map(); // albumKey → earliest YYYY-MM-DD /** artist → distinct track names played, for the "one-hit wonder" definition (one song, any number of plays). */ private artistTracks = new Map>(); + /** artist → distinct ISO week keys played in, for the "weeks active" ranking. */ + private artistWeeks = new Map>(); private minutesCount = 0; private total = 0; // ── New tracking fields ───────────────────────────────────────────────────── @@ -255,6 +259,13 @@ export class Aggregator { const weekKey = isoWeekKey(date); this.weeklyCounts.set(weekKey, (this.weeklyCounts.get(weekKey) ?? 0) + 1); + // Distinct weeks each artist appeared in — "weeks active" ranks + // artists you return to over a long span, separately from raw play + // count, which conflates that with a short, heavy binge. + const weeksForArtist = this.artistWeeks.get(artistName); + if (weeksForArtist) weeksForArtist.add(weekKey); + else this.artistWeeks.set(artistName, new Set([weekKey])); + // Monthly artist plays let monthArtists = this.monthlyArtistCounts.get(monthKey); if (!monthArtists) { @@ -408,6 +419,9 @@ export class Aggregator { monthlyArtistPlays: this.monthlyArtistCounts, weeklyScrobbles: new Map(this.weeklyCounts), artistTimestamps: this.artistTimestamps, + artistWeeksActive: new Map( + [...this.artistWeeks.entries()].map(([name, weeks]) => [name, weeks.size]), + ), eddingtonNumber, daysSinceFirstScrobble, daysScrobbled, diff --git a/packages/tourmaline/src/lib/client/load-profile.ts b/packages/tourmaline/src/lib/client/load-profile.ts index 3d1efd1..de9f8b7 100644 --- a/packages/tourmaline/src/lib/client/load-profile.ts +++ b/packages/tourmaline/src/lib/client/load-profile.ts @@ -113,6 +113,16 @@ export function computeProfile( .sort((a, b) => a.avgDaysBetween - b.avgDaysBetween) .slice(0, 20); + const topArtistsByWeeksActive = [...data.artistWeeksActive.entries()] + .filter(([, weeksActive]) => weeksActive >= 2) + .map(([name, weeksActive]) => ({ + name, + weeksActive, + count: data.artistPlayCounts.get(name) ?? 0, + })) + .sort((a, b) => b.weeksActive - a.weeksActive) + .slice(0, 10); + const profile: ListenerProfile = { did, handle, @@ -154,6 +164,7 @@ export function computeProfile( .map(([week, count]) => ({ week, count })) .sort((a, b) => a.week.localeCompare(b.week)), topArtistAvgDeltas, + topArtistsByWeeksActive, scrobbleMilestones: data.scrobbleMilestones, artistMilestones: data.artistMilestones, trackMilestones: data.trackMilestones, diff --git a/packages/tourmaline/src/lib/types.ts b/packages/tourmaline/src/lib/types.ts index 814469b..364b9ce 100644 --- a/packages/tourmaline/src/lib/types.ts +++ b/packages/tourmaline/src/lib/types.ts @@ -129,6 +129,8 @@ export interface ListenerProfile { avgDaysBetween: number; count: number; }>; + /** Top 10 artists by distinct weeks active — favourites returned to over a long span, not just a heavy binge. */ + topArtistsByWeeksActive: Array<{ name: string; weeksActive: number; count: number }>; /** Per-month most statistically unusual artist (highest z-score). */ unusualMonths: UnusualMonth[]; // ── Ported from lastfm-stats-web ───────────────────────────────────── diff --git a/packages/tourmaline/src/routes/profile/[did]/+page.svelte b/packages/tourmaline/src/routes/profile/[did]/+page.svelte index 4def672..2078247 100644 --- a/packages/tourmaline/src/routes/profile/[did]/+page.svelte +++ b/packages/tourmaline/src/routes/profile/[did]/+page.svelte @@ -32,6 +32,7 @@ import ListeningSessions from './ListeningSessions.svelte'; import OnThisDay from './OnThisDay.svelte'; import Anniversaries from './Anniversaries.svelte'; + import WeeksActive from './WeeksActive.svelte'; import DateRangePicker from './DateRangePicker.svelte'; import ListeningPhases from './ListeningPhases.svelte'; import StoryRecap from './StoryRecap.svelte'; @@ -517,6 +518,8 @@ + +
diff --git a/packages/tourmaline/src/routes/profile/[did]/CatalogueTab.svelte b/packages/tourmaline/src/routes/profile/[did]/CatalogueTab.svelte index bfd860b..471740c 100644 --- a/packages/tourmaline/src/routes/profile/[did]/CatalogueTab.svelte +++ b/packages/tourmaline/src/routes/profile/[did]/CatalogueTab.svelte @@ -3,6 +3,7 @@ import Discovery from './Discovery.svelte'; import OnThisDay from './OnThisDay.svelte'; import Anniversaries from './Anniversaries.svelte'; + import WeeksActive from './WeeksActive.svelte'; import type { ListenerProfile } from '$lib/types'; import type { OnThisDayEntry } from '$lib/analysis/on-this-day'; @@ -78,6 +79,8 @@
+ + {#if profile.discoveredArtists.length > 0 || profile.discoveredTracks.length > 0 || profile.discoveredAlbums.length > 0}
+ import { CalendarRange } from '@lucide/svelte'; + + let { artists }: { artists: Array<{ name: string; weeksActive: number; count: number }> } = $props(); + + +{#if artists.length > 0} +
+
+
+

Long-Time Favourites

+

Artists you keep returning to over many weeks, not just a heavy binge

+
+
+ +
+
+
    + {#each artists as artist, i (artist.name)} +
  1. + {i + 1} + {artist.name} + {artist.weeksActive} weeks +
  2. + {/each} +
+
+{/if}