diff --git a/packages/tourmaline/src/lib/analysis/anniversaries.ts b/packages/tourmaline/src/lib/analysis/anniversaries.ts new file mode 100644 index 0000000..35f8368 --- /dev/null +++ b/packages/tourmaline/src/lib/analysis/anniversaries.ts @@ -0,0 +1,86 @@ +import type { AggregatedData } from "./aggregator"; + +export interface Anniversary { + artist: string; + count: number; + /** Years since the artist's first listen, as of the nearest anniversary date. */ + years: number; + /** YYYY-MM-DD of the anniversary date itself. */ + date: string; + /** Signed day offset from today (negative = past, 0 = today, positive = upcoming). */ + daysDiff: number; +} + +export interface AnniversaryGroups { + today: Anniversary[]; + upcoming: Anniversary[]; + past: Anniversary[]; +} + +const WINDOW_DAYS = 14; +const DAY_MS = 86_400_000; +const TOP_N = 5; + +function pad(n: number): string { + return String(n).padStart(2, "0"); +} + +function toDateKey(d: Date): string { + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} + +/** + * "You discovered [artist] N years ago" — buckets each artist's nearest + * first-listen anniversary into today/upcoming/past (within a 2-week + * window), picking whichever of last/this/next year's anniversary date is + * closest to today. Ranked within each bucket by play count. + * + * Ported from lastfm-stats-web's anniversaries$ (general.component.ts). + */ +export function buildAnniversaries(data: AggregatedData): AnniversaryGroups { + const now = new Date(); + const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const todayMs = todayStart.getTime(); + + const groups: AnniversaryGroups = { today: [], upcoming: [], past: [] }; + + for (const [artist, firstListenStr] of data.artistFirstListen) { + const firstDate = new Date(firstListenStr + "T00:00:00"); + if (isNaN(firstDate.getTime())) continue; + + let best: { date: Date; daysDiff: number; years: number } | null = null; + for (const offset of [-1, 0, 1]) { + const anniversaryYear = todayStart.getFullYear() + offset; + const years = anniversaryYear - firstDate.getFullYear(); + if (years < 1) continue; + + const date = new Date(anniversaryYear, firstDate.getMonth(), firstDate.getDate()); + const daysDiff = Math.round((date.getTime() - todayMs) / DAY_MS); + if (!best || Math.abs(daysDiff) < Math.abs(best.daysDiff)) { + best = { date, daysDiff, years }; + } + } + if (!best) continue; + + const entry: Anniversary = { + artist, + count: data.artistPlayCounts.get(artist) ?? 0, + years: best.years, + date: toDateKey(best.date), + daysDiff: best.daysDiff, + }; + + if (best.daysDiff === 0) groups.today.push(entry); + else if (best.daysDiff > 0 && best.daysDiff <= WINDOW_DAYS) groups.upcoming.push(entry); + else if (best.daysDiff < 0 && best.daysDiff >= -WINDOW_DAYS) groups.past.push(entry); + } + + const byPlayCount = (arr: Anniversary[]) => + arr.sort((a, b) => b.count - a.count).slice(0, TOP_N); + + return { + today: byPlayCount(groups.today), + upcoming: byPlayCount(groups.upcoming).sort((a, b) => a.daysDiff - b.daysDiff), + past: byPlayCount(groups.past).sort((a, b) => b.daysDiff - a.daysDiff), + }; +} diff --git a/packages/tourmaline/src/lib/client/load-profile.ts b/packages/tourmaline/src/lib/client/load-profile.ts index 81453b3..3d1efd1 100644 --- a/packages/tourmaline/src/lib/client/load-profile.ts +++ b/packages/tourmaline/src/lib/client/load-profile.ts @@ -33,6 +33,7 @@ import { buildPersonality } from "$lib/analysis/personality"; 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"; export type RangeKey = "all" | "7d" | "30d" | "90d" | "365d"; export const RANGES: RangeKey[] = ["all", "7d", "30d", "90d", "365d"]; @@ -159,6 +160,7 @@ export function computeProfile( albumMilestones: data.albumMilestones, longestNotListenedGap: data.longestNotListenedGap, recommendations: buildRecommendations(data.topArtists, data.allArtists, artistInfos), + anniversaries: buildAnniversaries(data), }; const sessions = deriveSessions(filtered); diff --git a/packages/tourmaline/src/lib/types.ts b/packages/tourmaline/src/lib/types.ts index dd4cf32..814469b 100644 --- a/packages/tourmaline/src/lib/types.ts +++ b/packages/tourmaline/src/lib/types.ts @@ -1,5 +1,6 @@ import type { ListeningPhase } from "$lib/analysis/phases"; import type { Recommendation } from "$lib/analysis/recommendations"; +import type { AnniversaryGroups } from "$lib/analysis/anniversaries"; export interface TealScrobble { trackName: string; @@ -137,6 +138,7 @@ export interface ListenerProfile { albumMilestones: Milestone[]; longestNotListenedGap: Gap | null; recommendations: Recommendation[]; + anniversaries: AnniversaryGroups; } export interface UnusualMonth { diff --git a/packages/tourmaline/src/routes/profile/[did]/+page.svelte b/packages/tourmaline/src/routes/profile/[did]/+page.svelte index c3e6515..4def672 100644 --- a/packages/tourmaline/src/routes/profile/[did]/+page.svelte +++ b/packages/tourmaline/src/routes/profile/[did]/+page.svelte @@ -31,6 +31,7 @@ import ProfileTabs from './ProfileTabs.svelte'; import ListeningSessions from './ListeningSessions.svelte'; import OnThisDay from './OnThisDay.svelte'; + import Anniversaries from './Anniversaries.svelte'; import DateRangePicker from './DateRangePicker.svelte'; import ListeningPhases from './ListeningPhases.svelte'; import StoryRecap from './StoryRecap.svelte'; @@ -576,6 +577,10 @@ {/if} + +
+ +
{/if} {/if} diff --git a/packages/tourmaline/src/routes/profile/[did]/Anniversaries.svelte b/packages/tourmaline/src/routes/profile/[did]/Anniversaries.svelte new file mode 100644 index 0000000..d3a34fa --- /dev/null +++ b/packages/tourmaline/src/routes/profile/[did]/Anniversaries.svelte @@ -0,0 +1,106 @@ + + +
+
+
+

Anniversaries

+

Artists you discovered around this date in previous years

+
+
+ +
+
+ + {#if total === 0} +

No artist anniversaries within two weeks of today.

+ {:else} +
+ {#each [{ label: 'Today', items: groups.today }, { label: 'Upcoming', items: groups.upcoming }, { label: 'Recent', items: groups.past }] as section (section.label)} + {#if section.items.length > 0} +
+

{section.label}

+
+ {#each section.items as entry, i (entry.artist)} +
+
+

{entry.artist}

+

{yearsLabel(entry.years)} — {relativeLabel(entry)}

+
+
+

{entry.count.toLocaleString()}

+

scrobbles

+
+
+ {/each} +
+
+ {/if} + {/each} +
+ {/if} +
+ + diff --git a/packages/tourmaline/src/routes/profile/[did]/CatalogueTab.svelte b/packages/tourmaline/src/routes/profile/[did]/CatalogueTab.svelte index 65c79f6..bfd860b 100644 --- a/packages/tourmaline/src/routes/profile/[did]/CatalogueTab.svelte +++ b/packages/tourmaline/src/routes/profile/[did]/CatalogueTab.svelte @@ -2,6 +2,7 @@ import { renderNoiseAvatar } from '@ewanc26/noise-avatar'; import Discovery from './Discovery.svelte'; import OnThisDay from './OnThisDay.svelte'; + import Anniversaries from './Anniversaries.svelte'; import type { ListenerProfile } from '$lib/types'; import type { OnThisDayEntry } from '$lib/analysis/on-this-day'; @@ -92,3 +93,7 @@ {/if} + +
+ +