diff --git a/packages/tourmaline/src/lib/analysis/rank-history.ts b/packages/tourmaline/src/lib/analysis/rank-history.ts new file mode 100644 index 0000000..bb89eba --- /dev/null +++ b/packages/tourmaline/src/lib/analysis/rank-history.ts @@ -0,0 +1,92 @@ +/** + * Per-month rank snapshots for every artist, by cumulative play count up + * to and including that month. Feeds "biggest climbers/fallers" — whose + * rank improved/worsened most between the two most recent months they + * both appear in. Ported from lastfm-stats-web's StatsBuilderService rank + * population + AbstractListsComponent.getRankings(). + * + * Scoped to artists only (not tracks/albums) — tourmaline doesn't track a + * monthly per-track/album breakdown the way it does for artists, and + * artist-level climbers/fallers already covers the highest-value case. + */ +export interface RankSnapshot { + month: string; + rank: number; + cumulativeCount: number; +} + +export function buildRankHistory( + monthlyArtistPlays: Map>, +): Map { + const months = [...monthlyArtistPlays.keys()].sort(); + const cumulative = new Map(); + const history = new Map(); + + for (const month of months) { + const monthPlays = monthlyArtistPlays.get(month)!; + for (const [artist, count] of monthPlays) { + cumulative.set(artist, (cumulative.get(artist) ?? 0) + count); + } + + const ranked = [...cumulative.entries()].sort((a, b) => b[1] - a[1]); + ranked.forEach(([artist, count], idx) => { + let hist = history.get(artist); + if (!hist) { + hist = []; + history.set(artist, hist); + } + hist.push({ month, rank: idx + 1, cumulativeCount: count }); + }); + } + + return history; +} + +export interface RankMover { + name: string; + fromRank: number; + toRank: number; + delta: number; +} + +/** + * Artists whose rank changed most between the two most recent months in + * the history (only counting artists present in both). Positive delta = + * climber (rank number went down, i.e. improved); negative = faller. + */ +export function biggestMovers( + history: Map, + limit: number, + minCumulativeCount = 5, +): { climbers: RankMover[]; fallers: RankMover[] } { + const allMonths = new Set(); + for (const entries of history.values()) { + for (const e of entries) allMonths.add(e.month); + } + const months = [...allMonths].sort(); + if (months.length < 2) return { climbers: [], fallers: [] }; + + const [prevMonth, currMonth] = months.slice(-2); + const movers: RankMover[] = []; + + for (const [name, entries] of history) { + const prev = entries.find((e) => e.month === prevMonth); + const curr = entries.find((e) => e.month === currMonth); + if (!prev || !curr) continue; + if (curr.cumulativeCount < minCumulativeCount) continue; + const delta = prev.rank - curr.rank; + if (delta === 0) continue; + movers.push({ name, fromRank: prev.rank, toRank: curr.rank, delta }); + } + + const climbers = movers + .filter((m) => m.delta > 0) + .sort((a, b) => b.delta - a.delta) + .slice(0, limit); + const fallers = movers + .filter((m) => m.delta < 0) + .sort((a, b) => a.delta - b.delta) + .slice(0, limit); + + return { climbers, fallers }; +} diff --git a/packages/tourmaline/src/lib/client/load-profile.ts b/packages/tourmaline/src/lib/client/load-profile.ts index 80c3c1c..b7766b8 100644 --- a/packages/tourmaline/src/lib/client/load-profile.ts +++ b/packages/tourmaline/src/lib/client/load-profile.ts @@ -36,6 +36,7 @@ import { buildRecommendations } from "$lib/analysis/recommendations"; import { buildAnniversaries } from "$lib/analysis/anniversaries"; import { topGaps } from "$lib/analysis/gaps"; import { topByAvgDate } from "$lib/analysis/average-listen-date"; +import { buildRankHistory, biggestMovers } from "$lib/analysis/rank-history"; export type RangeKey = "all" | "7d" | "30d" | "90d" | "365d"; export const RANGES: RangeKey[] = ["all", "7d", "30d", "90d", "365d"]; @@ -158,6 +159,9 @@ export function computeProfile( "newest", ).map((e) => ({ name: e.key, avgDate: new Date(e.avgTimestamp).toISOString().slice(0, 10), count: e.count })); + const rankHistory = buildRankHistory(data.monthlyArtistPlays); + const { climbers: biggestClimbers, fallers: biggestFallers } = biggestMovers(rankHistory, 10); + const profile: ListenerProfile = { did, handle, @@ -207,6 +211,8 @@ export function computeProfile( topArtistsByTrackCount, goldenOldieArtists, latestDiscoveryArtists, + biggestClimbers, + biggestFallers, 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 5e0c26a..238c8b3 100644 --- a/packages/tourmaline/src/lib/types.ts +++ b/packages/tourmaline/src/lib/types.ts @@ -1,6 +1,7 @@ import type { ListeningPhase } from "$lib/analysis/phases"; import type { Recommendation } from "$lib/analysis/recommendations"; import type { AnniversaryGroups } from "$lib/analysis/anniversaries"; +import type { RankMover } from "$lib/analysis/rank-history"; export interface TealScrobble { trackName: string; @@ -141,6 +142,9 @@ export interface ListenerProfile { /** Top 10 artists by earliest/latest average listen date (min 5 plays). */ goldenOldieArtists: Array<{ name: string; avgDate: string; count: number }>; latestDiscoveryArtists: Array<{ name: string; avgDate: string; count: number }>; + /** Artists whose rank (by cumulative play count) moved most between the two most recent months. */ + biggestClimbers: RankMover[]; + biggestFallers: RankMover[]; /** 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 a5c3f5a..aec5ccf 100644 --- a/packages/tourmaline/src/routes/profile/[did]/+page.svelte +++ b/packages/tourmaline/src/routes/profile/[did]/+page.svelte @@ -39,6 +39,7 @@ import TopPeriods from './TopPeriods.svelte'; import LetterChart from './LetterChart.svelte'; import GoldenOldies from './GoldenOldies.svelte'; + import RankMovers from './RankMovers.svelte'; import DateRangePicker from './DateRangePicker.svelte'; import ListeningPhases from './ListeningPhases.svelte'; import StoryRecap from './StoryRecap.svelte'; @@ -551,6 +552,7 @@ {/if} +
diff --git a/packages/tourmaline/src/routes/profile/[did]/RankMovers.svelte b/packages/tourmaline/src/routes/profile/[did]/RankMovers.svelte new file mode 100644 index 0000000..df20dae --- /dev/null +++ b/packages/tourmaline/src/routes/profile/[did]/RankMovers.svelte @@ -0,0 +1,46 @@ + + +{#if climbers.length > 0 || fallers.length > 0} +
+ {#if climbers.length > 0} +
+
+

Biggest Climbers

+
+
+
    + {#each climbers as artist, i (artist.name)} +
  1. + {i + 1} + {artist.name} + #{artist.fromRank} → #{artist.toRank} +
  2. + {/each} +
+
+ {/if} + + {#if fallers.length > 0} +
+
+

Biggest Fallers

+
+
+
    + {#each fallers as artist, i (artist.name)} +
  1. + {i + 1} + {artist.name} + #{artist.fromRank} → #{artist.toRank} +
  2. + {/each} +
+
+ {/if} +
+{/if}