diff --git a/packages/tourmaline/src/lib/analysis/every-year.ts b/packages/tourmaline/src/lib/analysis/every-year.ts new file mode 100644 index 0000000..7ce4c2d --- /dev/null +++ b/packages/tourmaline/src/lib/analysis/every-year.ts @@ -0,0 +1,46 @@ +/** + * Artists scrobbled in every calendar year of the listener's history — + * and a variant excluding the first and last year, since those are often + * partial (you didn't necessarily start listening on Jan 1). Ported from + * lastfm-stats-web's "every year artists" / "every completed year artists". + */ +function artistYearSets(monthlyArtistPlays: Map>): Map> { + const artistYears = new Map>(); + for (const [month, artists] of monthlyArtistPlays) { + const year = month.slice(0, 4); + for (const artist of artists.keys()) { + let set = artistYears.get(artist); + if (!set) { + set = new Set(); + artistYears.set(artist, set); + } + set.add(year); + } + } + return artistYears; +} + +function filterEveryYear(artistYears: Map>, years: string[]): string[] { + if (years.length === 0) return []; + return [...artistYears.entries()] + .filter(([, set]) => years.every((y) => set.has(y))) + .map(([artist]) => artist); +} + +export interface EveryYearResult { + everyYear: string[]; + everyCompletedYear: string[]; +} + +export function buildEveryYearArtists(monthlyArtistPlays: Map>): EveryYearResult { + const years = [...new Set([...monthlyArtistPlays.keys()].map((m) => m.slice(0, 4)))].sort(); + const artistYears = artistYearSets(monthlyArtistPlays); + + const everyYear = filterEveryYear(artistYears, years); + // Completed years excludes the first and last (possibly partial) year — + // only meaningful once there's a middle year to check against. + const completedYears = years.length > 2 ? years.slice(1, -1) : []; + const everyCompletedYear = filterEveryYear(artistYears, completedYears); + + return { everyYear, everyCompletedYear }; +} diff --git a/packages/tourmaline/src/lib/client/load-profile.ts b/packages/tourmaline/src/lib/client/load-profile.ts index d20969f..e044c4b 100644 --- a/packages/tourmaline/src/lib/client/load-profile.ts +++ b/packages/tourmaline/src/lib/client/load-profile.ts @@ -38,6 +38,7 @@ import { topGaps } from "$lib/analysis/gaps"; import { topByAvgDate } from "$lib/analysis/average-listen-date"; import { buildRankHistory, biggestMovers } from "$lib/analysis/rank-history"; import { mostNewArtistsMonth, mostListenedNewArtistInMonth } from "$lib/analysis/new-artists"; +import { buildEveryYearArtists } from "$lib/analysis/every-year"; export type RangeKey = "all" | "7d" | "30d" | "90d" | "365d"; export const RANGES: RangeKey[] = ["all", "7d", "30d", "90d", "365d"]; @@ -166,6 +167,14 @@ export function computeProfile( const mostNewArtistsInAMonth = mostNewArtistsMonth(data.artistFirstListen); const mostListenedNewArtist = mostListenedNewArtistInMonth(data.artistFirstListen, data.monthlyArtistPlays); + const everyYear = buildEveryYearArtists(data.monthlyArtistPlays); + const toRanked = (names: string[]) => + names + .map((name) => ({ name, count: data.artistPlayCounts.get(name) ?? 0 })) + .sort((a, b) => b.count - a.count); + const everyYearArtists = toRanked(everyYear.everyYear); + const everyCompletedYearArtists = toRanked(everyYear.everyCompletedYear); + const profile: ListenerProfile = { did, handle, @@ -220,6 +229,8 @@ export function computeProfile( biggestFallers, mostNewArtistsInAMonth, mostListenedNewArtist, + everyYearArtists, + everyCompletedYearArtists, 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 e9f4393..450bdf2 100644 --- a/packages/tourmaline/src/lib/types.ts +++ b/packages/tourmaline/src/lib/types.ts @@ -149,6 +149,10 @@ export interface ListenerProfile { biggestFallers: RankMover[]; mostNewArtistsInAMonth: NewArtistMonthStat | null; mostListenedNewArtist: MostListenedNewArtist | null; + /** Artists scrobbled in every calendar year of the listener's history, ranked by play count. */ + everyYearArtists: Array<{ name: string; count: number }>; + /** Same, excluding the first and last (possibly partial) year. */ + everyCompletedYearArtists: Array<{ name: string; 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 aec5ccf..3029751 100644 --- a/packages/tourmaline/src/routes/profile/[did]/+page.svelte +++ b/packages/tourmaline/src/routes/profile/[did]/+page.svelte @@ -40,6 +40,7 @@ import LetterChart from './LetterChart.svelte'; import GoldenOldies from './GoldenOldies.svelte'; import RankMovers from './RankMovers.svelte'; + import EveryYearArtists from './EveryYearArtists.svelte'; import DateRangePicker from './DateRangePicker.svelte'; import ListeningPhases from './ListeningPhases.svelte'; import StoryRecap from './StoryRecap.svelte'; @@ -553,6 +554,7 @@ +
diff --git a/packages/tourmaline/src/routes/profile/[did]/EveryYearArtists.svelte b/packages/tourmaline/src/routes/profile/[did]/EveryYearArtists.svelte new file mode 100644 index 0000000..a321190 --- /dev/null +++ b/packages/tourmaline/src/routes/profile/[did]/EveryYearArtists.svelte @@ -0,0 +1,62 @@ + + +{#if everyYear.length > 0} +
+
+
+

Every-Year Artists

+

+ Artists you've scrobbled in {showCompletedOnly ? 'every completed calendar year' : 'every calendar year'} of your history +

+
+
+
+ + {#if everyCompletedYear.length > 0} +
+ + +
+ {/if} + + {#if list.length === 0} +

No artists span every completed year yet.

+ {:else} +
    + {#each list.slice(0, 20) as artist, i (artist.name)} +
  1. + {i + 1} + {artist.name} + {artist.count.toLocaleString()} +
  2. + {/each} +
+ {#if list.length > 20} +

+{list.length - 20} more

+ {/if} + {/if} +
+{/if}