diff --git a/packages/tourmaline/src/lib/client/visit-history.ts b/packages/tourmaline/src/lib/client/visit-history.ts new file mode 100644 index 0000000..0641c04 --- /dev/null +++ b/packages/tourmaline/src/lib/client/visit-history.ts @@ -0,0 +1,80 @@ +/** + * "Since your last visit" delta — localStorage only, no server infra. + * + * A full recurring digest (email/Bluesky push on a schedule) would need + * external infrastructure this stateless app doesn't have (a scheduler, an + * email provider, a subscriber list). This is the on-demand equivalent: + * remember what a listener's profile looked like the last time they opened + * it on this browser, and surface what's changed since, computed the + * moment they return rather than pushed to them. + */ +import type { ListenerProfile } from "$lib/types"; + +interface VisitSnapshot { + totalScrobbles: number; + uniqueArtists: number; + /** ms epoch */ + timestamp: number; +} + +export interface VisitDelta { + daysSinceLastVisit: number; + newScrobbles: number; + newArtistCount: number; + /** Artists first heard since the last visit, most recent first. */ + newArtists: string[]; +} + +const KEY_PREFIX = "tm:visit:"; + +function readSnapshot(did: string): VisitSnapshot | null { + try { + const raw = localStorage.getItem(KEY_PREFIX + did); + if (!raw) return null; + return JSON.parse(raw) as VisitSnapshot; + } catch { + return null; + } +} + +function writeSnapshot(did: string, profile: ListenerProfile): void { + try { + const snapshot: VisitSnapshot = { + totalScrobbles: profile.totalScrobbles, + uniqueArtists: profile.uniqueArtists, + timestamp: Date.now(), + }; + localStorage.setItem(KEY_PREFIX + did, JSON.stringify(snapshot)); + } catch { + /* storage full or unavailable — silent fail */ + } +} + +/** + * Reads the last-visit snapshot (if any), computes what's changed against + * the current profile, then overwrites the snapshot with the current state. + * Call once per completed profile load — returns null on a first-ever visit. + */ +export function checkInAndComputeDelta(did: string, profile: ListenerProfile): VisitDelta | null { + const previous = readSnapshot(did); + writeSnapshot(did, profile); + + if (!previous) return null; + + const daysSinceLastVisit = Math.floor((Date.now() - previous.timestamp) / (1000 * 60 * 60 * 24)); + + // Nothing meaningful to report for a same-day repeat visit. + if (daysSinceLastVisit < 1) return null; + + const newArtists = profile.discoveredArtists + .filter((a) => new Date(a.firstListen + "T00:00:00Z").getTime() > previous.timestamp) + .sort((a, b) => b.firstListen.localeCompare(a.firstListen)) + .map((a) => a.name); + + return { + daysSinceLastVisit, + newScrobbles: Math.max(0, profile.totalScrobbles - previous.totalScrobbles), + newArtistCount: Math.max(0, profile.uniqueArtists - previous.uniqueArtists), + newArtists, + }; +} diff --git a/packages/tourmaline/src/routes/profile/[did]/+page.svelte b/packages/tourmaline/src/routes/profile/[did]/+page.svelte index c59d872..c60961e 100644 --- a/packages/tourmaline/src/routes/profile/[did]/+page.svelte +++ b/packages/tourmaline/src/routes/profile/[did]/+page.svelte @@ -41,6 +41,7 @@ import Recommendations from './Recommendations.svelte'; import TrackPreview from '$lib/components/TrackPreview.svelte'; import ListeningContext from './ListeningContext.svelte'; + import { checkInAndComputeDelta, type VisitDelta } from '$lib/client/visit-history'; function noiseAvatar(canvas: HTMLCanvasElement, seed: string) { renderNoiseAvatar(canvas, seed, { displaySize: 32, gridSize: 5 }); @@ -88,6 +89,9 @@ let elapsed = $state(0); let enrichElapsed = $state(0); + // Since-last-visit delta (localStorage only, no server tracking) + let visitDelta = $state(null); + // Profile data let results = $state(emptyResults()); @@ -127,6 +131,12 @@ }, onResults: (r) => { results = r; } }); + + const allTimeProfile = results.all?.profile; + if (allTimeProfile && allTimeProfile.totalScrobbles > 0) { + visitDelta = checkInAndComputeDelta(did, allTimeProfile); + } + console.log(`[tourmaline] complete in ${((performance.now() - t0) / 1000).toFixed(1)}s`); } catch (e) { phase = 'error'; @@ -169,6 +179,26 @@ {/if} + + {#if visitDelta} +
+

+ Welcome back — {visitDelta.daysSinceLastVisit === 1 ? 'a day' : `${visitDelta.daysSinceLastVisit} days`} since your last visit. +

+

+ {#if visitDelta.newScrobbles > 0} + +{visitDelta.newScrobbles.toLocaleString()} scrobble{visitDelta.newScrobbles === 1 ? '' : 's'} + {/if} + {#if visitDelta.newArtists.length > 0} + {visitDelta.newScrobbles > 0 ? '· ' : ''}{visitDelta.newArtists.length} new artist{visitDelta.newArtists.length === 1 ? '' : 's'}: {visitDelta.newArtists.slice(0, 5).join(', ')}{visitDelta.newArtists.length > 5 ? `, +${visitDelta.newArtists.length - 5} more` : ''} + {/if} + {#if visitDelta.newScrobbles === 0 && visitDelta.newArtists.length === 0} + No new scrobbles since then. + {/if} +

+
+ {/if} + {#if phase === 'fetching' || phase === 'idle'}