diff --git a/packages/tourmaline/src/lib/analysis/average-listen-date.ts b/packages/tourmaline/src/lib/analysis/average-listen-date.ts new file mode 100644 index 0000000..647ed2f --- /dev/null +++ b/packages/tourmaline/src/lib/analysis/average-listen-date.ts @@ -0,0 +1,32 @@ +/** + * "Golden oldies" (earliest average listen date — long-time favourites) vs + * "Latest discoveries" (most recent average listen date) — ranks items by + * the mean of every timestamp they've been played at, not just when they + * were first heard. An artist first heard years ago but rarely revisited + * since won't rank as a golden oldie; one you've listened to steadily the + * whole time will. Ported from lastfm-stats-web's avgScrobble ranking. + */ +export interface AvgDateEntry { + key: string; + avgTimestamp: number; + count: number; +} + +export function topByAvgDate( + timestampsByKey: Map, + playCounts: Map, + minPlays: number, + limit: number, + order: "oldest" | "newest", +): AvgDateEntry[] { + const entries: AvgDateEntry[] = []; + for (const [key, timestamps] of timestampsByKey) { + if (timestamps.length < minPlays) continue; + const avgTimestamp = timestamps.reduce((a, b) => a + b, 0) / timestamps.length; + entries.push({ key, avgTimestamp, count: playCounts.get(key) ?? timestamps.length }); + } + entries.sort((a, b) => + order === "oldest" ? a.avgTimestamp - b.avgTimestamp : b.avgTimestamp - a.avgTimestamp, + ); + return entries.slice(0, limit); +} diff --git a/packages/tourmaline/src/lib/client/load-profile.ts b/packages/tourmaline/src/lib/client/load-profile.ts index b40e728..80c3c1c 100644 --- a/packages/tourmaline/src/lib/client/load-profile.ts +++ b/packages/tourmaline/src/lib/client/load-profile.ts @@ -35,6 +35,7 @@ import { topZScorePerMonth } from "$lib/analysis/zscore"; 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"; export type RangeKey = "all" | "7d" | "30d" | "90d" | "365d"; export const RANGES: RangeKey[] = ["all", "7d", "30d", "90d", "365d"]; @@ -146,6 +147,17 @@ export function computeProfile( .sort((a, b) => b.trackCount - a.trackCount) .slice(0, 10); + const goldenOldieArtists = topByAvgDate(data.artistTimestamps, data.artistPlayCounts, 5, 10, "oldest").map( + (e) => ({ name: e.key, avgDate: new Date(e.avgTimestamp).toISOString().slice(0, 10), count: e.count }), + ); + const latestDiscoveryArtists = topByAvgDate( + data.artistTimestamps, + data.artistPlayCounts, + 5, + 10, + "newest", + ).map((e) => ({ name: e.key, avgDate: new Date(e.avgTimestamp).toISOString().slice(0, 10), count: e.count })); + const profile: ListenerProfile = { did, handle, @@ -193,6 +205,8 @@ export function computeProfile( topArtistGaps, topTrackGaps, topArtistsByTrackCount, + goldenOldieArtists, + latestDiscoveryArtists, 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 bddaa83..5e0c26a 100644 --- a/packages/tourmaline/src/lib/types.ts +++ b/packages/tourmaline/src/lib/types.ts @@ -138,6 +138,9 @@ export interface ListenerProfile { topTrackGaps: Array<{ name: string; artist: string; gapDays: number; count: number }>; /** Top 10 artists by distinct tracks played — breadth within one artist's catalogue. */ topArtistsByTrackCount: Array<{ name: string; trackCount: number; count: number }>; + /** 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 }>; /** 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 a634e74..a5c3f5a 100644 --- a/packages/tourmaline/src/routes/profile/[did]/+page.svelte +++ b/packages/tourmaline/src/routes/profile/[did]/+page.svelte @@ -38,6 +38,7 @@ import CatalogueDepth from './CatalogueDepth.svelte'; import TopPeriods from './TopPeriods.svelte'; import LetterChart from './LetterChart.svelte'; + import GoldenOldies from './GoldenOldies.svelte'; import DateRangePicker from './DateRangePicker.svelte'; import ListeningPhases from './ListeningPhases.svelte'; import StoryRecap from './StoryRecap.svelte'; @@ -549,6 +550,8 @@ {/if} + +
diff --git a/packages/tourmaline/src/routes/profile/[did]/GoldenOldies.svelte b/packages/tourmaline/src/routes/profile/[did]/GoldenOldies.svelte new file mode 100644 index 0000000..4561caf --- /dev/null +++ b/packages/tourmaline/src/routes/profile/[did]/GoldenOldies.svelte @@ -0,0 +1,64 @@ + + +{#if goldenOldies.length > 0 || latestDiscoveries.length > 0} +
+ {#if goldenOldies.length > 0} +
+
+
+

Golden Oldies

+

Steady favourites, weighted by when you actually listen to them

+
+
+
+
    + {#each goldenOldies as artist, i (artist.name)} +
  1. + {i + 1} + {artist.name} + {formatDate(artist.avgDate)} +
  2. + {/each} +
+
+ {/if} + + {#if latestDiscoveries.length > 0} +
+
+
+

Latest Discoveries

+

Artists whose listening skews most recent

+
+
+
+
    + {#each latestDiscoveries as artist, i (artist.name)} +
  1. + {i + 1} + {artist.name} + {formatDate(artist.avgDate)} +
  2. + {/each} +
+
+ {/if} +
+{/if}