From bf026c6f6f729b19effc0da444c7dd03f32faa72 Mon Sep 17 00:00:00 2001 From: Ewan Croft Date: Thu, 13 Aug 2026 15:10:56 +0100 Subject: [PATCH] feat(tourmaline): add longest-gap-between-listens lists for artists/tracks Distinct from average delta (the mean spacing between plays): this surfaces the single biggest silence for an artist/track you otherwise listen to regularly. Added per-track timestamp tracking (artist-level already existed) and a shared topGaps() helper, filtered to items with at least 5 plays so a barely-played item's one big gap doesn't dominate the list. Ported from lastfm-stats-web's "gaps between X" lists. --- .../tourmaline/src/lib/analysis/aggregator.ts | 9 +++ packages/tourmaline/src/lib/analysis/gaps.ts | 42 +++++++++++ .../tourmaline/src/lib/client/load-profile.ts | 16 +++++ packages/tourmaline/src/lib/types.ts | 3 + .../src/routes/profile/[did]/+page.svelte | 2 + .../routes/profile/[did]/BiggestGaps.svelte | 69 +++++++++++++++++++ .../routes/profile/[did]/CatalogueTab.svelte | 2 + 7 files changed, 143 insertions(+) create mode 100644 packages/tourmaline/src/lib/analysis/gaps.ts create mode 100644 packages/tourmaline/src/routes/profile/[did]/BiggestGaps.svelte diff --git a/packages/tourmaline/src/lib/analysis/aggregator.ts b/packages/tourmaline/src/lib/analysis/aggregator.ts index 1791f73..1729781 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; + /** Sorted per-track (trackKey) timestamp arrays (ms since epoch) for gap analysis */ + trackTimestamps: Map; /** artist → count of distinct ISO weeks played in. */ artistWeeksActive: Map; // ── Derived stats computed in snapshot() ────────────────────────────── @@ -136,6 +138,7 @@ export class Aggregator { // ── New tracking fields ───────────────────────────────────────────────────── private weeklyCounts = new Map(); private artistTimestamps = new Map(); + private trackTimestamps = new Map(); private artistStreak = new ItemStreakStack(); private trackStreak = new ItemStreakStack(); @@ -204,6 +207,11 @@ export class Aggregator { else this.artistTracks.set(artistName, new Set([scrobble.trackName])); const trackKey = TRACK_KEY(scrobble); + + const trackTs = this.trackTimestamps.get(trackKey); + if (trackTs) trackTs.push(date.getTime()); + else this.trackTimestamps.set(trackKey, [date.getTime()]); + const existing = this.trackCounts.get(trackKey); if (existing) { existing.count++; @@ -427,6 +435,7 @@ export class Aggregator { monthlyArtistPlays: this.monthlyArtistCounts, weeklyScrobbles: new Map(this.weeklyCounts), artistTimestamps: this.artistTimestamps, + trackTimestamps: this.trackTimestamps, artistWeeksActive: new Map( [...this.artistWeeks.entries()].map(([name, weeks]) => [name, weeks.size]), ), diff --git a/packages/tourmaline/src/lib/analysis/gaps.ts b/packages/tourmaline/src/lib/analysis/gaps.ts new file mode 100644 index 0000000..911a146 --- /dev/null +++ b/packages/tourmaline/src/lib/analysis/gaps.ts @@ -0,0 +1,42 @@ +/** + * Longest gap between two consecutive listens of the same item — distinct + * from average delta (topArtistAvgDeltas), which is the *mean* spacing: + * this surfaces the single biggest silence for an artist/track you + * otherwise listen to regularly. Ported from lastfm-stats-web's + * "gaps between X" lists. + */ +export interface GapEntry { + key: string; + gapDays: number; + count: number; +} + +/** Longest gap (ms) between two consecutive timestamps in a sorted array. */ +export function longestGapMs(timestamps: number[]): number { + let max = 0; + for (let i = 1; i < timestamps.length; i++) { + max = Math.max(max, timestamps[i] - timestamps[i - 1]); + } + return max; +} + +/** + * Top items ranked by their longest internal gap between listens. Filters + * to items with at least `minPlays` occurrences so a barely-played item + * with one big gap doesn't dominate the list. + */ +export function topGaps( + timestampsByKey: Map, + playCounts: Map, + minPlays: number, + limit: number, +): GapEntry[] { + const entries: GapEntry[] = []; + for (const [key, timestamps] of timestampsByKey) { + if (timestamps.length < minPlays) continue; + const gapMs = longestGapMs(timestamps); + if (gapMs <= 0) continue; + entries.push({ key, gapDays: gapMs / 86_400_000, count: playCounts.get(key) ?? timestamps.length }); + } + return entries.sort((a, b) => b.gapDays - a.gapDays).slice(0, limit); +} diff --git a/packages/tourmaline/src/lib/client/load-profile.ts b/packages/tourmaline/src/lib/client/load-profile.ts index 32122c1..31b3d0c 100644 --- a/packages/tourmaline/src/lib/client/load-profile.ts +++ b/packages/tourmaline/src/lib/client/load-profile.ts @@ -34,6 +34,7 @@ import { filterScrobbles, presetRange } from "$lib/analysis/date-range"; import { topZScorePerMonth } from "$lib/analysis/zscore"; import { buildRecommendations } from "$lib/analysis/recommendations"; import { buildAnniversaries } from "$lib/analysis/anniversaries"; +import { topGaps } from "$lib/analysis/gaps"; export type RangeKey = "all" | "7d" | "30d" | "90d" | "365d"; export const RANGES: RangeKey[] = ["all", "7d", "30d", "90d", "365d"]; @@ -123,6 +124,19 @@ export function computeProfile( .sort((a, b) => b.weeksActive - a.weeksActive) .slice(0, 10); + const topArtistGaps = topGaps(data.artistTimestamps, data.artistPlayCounts, 5, 10).map((g) => ({ + name: g.key, + gapDays: g.gapDays, + count: g.count, + })); + + const topTrackGaps = topGaps(data.trackTimestamps, data.trackPlayCounts, 5, 10).map((g) => { + const sepIdx = g.key.indexOf("|||"); + const name = sepIdx >= 0 ? g.key.slice(0, sepIdx) : g.key; + const artist = sepIdx >= 0 ? g.key.slice(sepIdx + 3).split(",")[0] : ""; + return { name, artist, gapDays: g.gapDays, count: g.count }; + }); + const profile: ListenerProfile = { did, handle, @@ -167,6 +181,8 @@ export function computeProfile( .sort((a, b) => a.week.localeCompare(b.week)), topArtistAvgDeltas, topArtistsByWeeksActive, + topArtistGaps, + topTrackGaps, 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 ff592cf..0786576 100644 --- a/packages/tourmaline/src/lib/types.ts +++ b/packages/tourmaline/src/lib/types.ts @@ -133,6 +133,9 @@ export interface ListenerProfile { }>; /** 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 }>; + /** Top 10 artists/tracks by longest gap between two consecutive listens (min 5 plays). */ + topArtistGaps: Array<{ name: string; gapDays: number; count: number }>; + topTrackGaps: Array<{ name: string; artist: string; gapDays: 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 125d02f..f227c49 100644 --- a/packages/tourmaline/src/routes/profile/[did]/+page.svelte +++ b/packages/tourmaline/src/routes/profile/[did]/+page.svelte @@ -34,6 +34,7 @@ import Anniversaries from './Anniversaries.svelte'; import WeeksActive from './WeeksActive.svelte'; import ListeningCadence from './ListeningCadence.svelte'; + import BiggestGaps from './BiggestGaps.svelte'; import DateRangePicker from './DateRangePicker.svelte'; import ListeningPhases from './ListeningPhases.svelte'; import StoryRecap from './StoryRecap.svelte'; @@ -533,6 +534,7 @@ +
diff --git a/packages/tourmaline/src/routes/profile/[did]/BiggestGaps.svelte b/packages/tourmaline/src/routes/profile/[did]/BiggestGaps.svelte new file mode 100644 index 0000000..aa85255 --- /dev/null +++ b/packages/tourmaline/src/routes/profile/[did]/BiggestGaps.svelte @@ -0,0 +1,69 @@ + + +{#if artistGaps.length > 0 || trackGaps.length > 0} +
+ {#if artistGaps.length > 0} +
+
+
+

Biggest Silences — Artists

+

Longest gap between two listens of the same artist

+
+
+ +
+
+
    + {#each artistGaps as artist, i (artist.name)} +
  1. + {i + 1} + {artist.name} + {formatGap(artist.gapDays)} +
  2. + {/each} +
+
+ {/if} + + {#if trackGaps.length > 0} +
+
+
+

Biggest Silences — Tracks

+

Longest gap between two listens of the same track

+
+
+ +
+
+
    + {#each trackGaps as track, i (i)} +
  1. + {i + 1} + + {track.name} + — {track.artist} + + {formatGap(track.gapDays)} +
  2. + {/each} +
+
+ {/if} +
+{/if} diff --git a/packages/tourmaline/src/routes/profile/[did]/CatalogueTab.svelte b/packages/tourmaline/src/routes/profile/[did]/CatalogueTab.svelte index 6740a63..94bbe9c 100644 --- a/packages/tourmaline/src/routes/profile/[did]/CatalogueTab.svelte +++ b/packages/tourmaline/src/routes/profile/[did]/CatalogueTab.svelte @@ -5,6 +5,7 @@ import Anniversaries from './Anniversaries.svelte'; import WeeksActive from './WeeksActive.svelte'; import ListeningCadence from './ListeningCadence.svelte'; + import BiggestGaps from './BiggestGaps.svelte'; import type { ListenerProfile } from '$lib/types'; import type { OnThisDayEntry } from '$lib/analysis/on-this-day'; @@ -82,6 +83,7 @@ + {#if profile.discoveredArtists.length > 0 || profile.discoveredTracks.length > 0 || profile.discoveredAlbums.length > 0}
-- 2.51.2