Shows some quick stats about your teal.fm records. Kind of like Spotify Wrapped
1<script setup lang="ts">
2import {ref} from "vue";
3import {
4 CompositeDidDocumentResolver,
5 CompositeHandleResolver,
6 DohJsonHandleResolver,
7 PlcDidDocumentResolver,
8 WebDidDocumentResolver,
9 WellKnownHandleResolver,
10} from "@atcute/identity-resolver";
11import {AtpAgent} from "@atproto/api";
12import * as TID from "@atcute/tid";
13
14//Should be using the lexicons to generate types...
15// Types for fm.teal.alpha.feed.play
16interface PlayArtist {
17 artistMbId?: string;
18 artistName: string;
19}
20interface PlayValue {
21 $type?: "fm.teal.alpha.feed.play";
22 artists?: PlayArtist[];
23 // legacy support: earlier records may use artistNames
24 artistNames?: string[];
25 trackName: string;
26 playedTime: string; // ISO string
27 releaseMbId?: string;
28 releaseName?: string;
29 recordingMbId?: string;
30 submissionClientAgent?: string;
31}
32interface ListRecord<T> {
33 uri: string;
34 cid: string;
35 value: T;
36}
37
38// handle resolution
39const handleResolver = new CompositeHandleResolver({
40 strategy: "race",
41 methods: {
42 dns: new DohJsonHandleResolver({
43 dohUrl: "https://mozilla.cloudflare-dns.com/dns-query",
44 }),
45 http: new WellKnownHandleResolver(),
46 },
47});
48
49const docResolver = new CompositeDidDocumentResolver({
50 methods: {
51 plc: new PlcDidDocumentResolver(),
52 web: new WebDidDocumentResolver(),
53 },
54});
55
56const userHandle = ref("");
57const loading = ref(false);
58const artists = ref<{ name: string; plays: number }[]>([]);
59const tracks = ref<{ name: string; artist: string; plays: number }[]>([]);
60const topDays = ref<{ date: string; plays: number }[]>([]);
61const totalSongs = ref(0);
62const errorMessage = ref<string | null>(null);
63
64// Advanced controls
65const topLimit = ref<number>(25);
66const startDate = ref<string | null>(null); // format: YYYY-MM-DD (local)
67const endDate = ref<string | null>(null);
68
69const formatNumber = (n: number) => n.toLocaleString();
70
71// Returns YYYY-MM-DD in the browser's local time zone
72const localDayKey = (iso?: string): string | null => {
73 if (!iso) return null;
74 const d = new Date(iso);
75 if (isNaN(d.getTime())) return null;
76 const y = d.getFullYear();
77 const m = (d.getMonth() + 1).toString().padStart(2, "0");
78 const day = d.getDate().toString().padStart(2, "0");
79 return `${y}-${m}-${day}`;
80};
81
82const lookup = async () => {
83 loading.value = true;
84 //Clear values
85 errorMessage.value = null;
86 artists.value = [];
87 tracks.value = [];
88 topDays.value = [];
89 totalSongs.value = 0;
90
91 try {
92 const did = await handleResolver.resolve(
93 userHandle.value as `${string}.${string}`,
94 );
95
96 if (did == undefined) {
97 throw new Error("expected handle to resolve");
98 }
99 console.log(did); // did:plc:ewvi7nxzyoun6zhxrhs64oiz
100
101 const doc = await docResolver.resolve(did);
102 console.log(doc);
103
104 const endpoint = doc.service?.[0]?.serviceEndpoint as string | undefined;
105 if (!endpoint) {
106 throw new Error("could not resolve service endpoint for DID");
107 }
108 const agent = new AtpAgent({
109 service: endpoint,
110 });
111 let cursor: string | null = null;
112 let totalCount = 0;
113 let inner_tracks: { name: string; artist: string; plays: number }[] = [];
114 let inner_artists: { name: string; plays: number }[] = [];
115 const dayCountMap = new Map<string, number>();
116
117 //If dates are set want to reverse
118 const reverse: boolean = startDate.value !== null && endDate.value !== null;
119 if (reverse) {
120 //Couple checks on dates
121 if(endDate.value === null) {
122 endDate.value = new Date().toISOString().split('T')[0];
123 }
124
125 if (startDate.value !== null && startDate.value > endDate.value) {
126 throw new Error("Start date must be before end date");
127 }
128
129 //Get the tid of start time to filter in reverse since
130 const startDateTime = new Date(startDate.value + 'T00:00:00.000');
131 const micros = startDateTime.getTime() * 1000; // convert ms to µs
132 //Might as well use a lucky number for clock id
133 cursor = TID.create(micros, 23);
134 }
135
136 let response = await agent.com.atproto.repo.listRecords({
137 repo: did,
138 collection: "fm.teal.alpha.feed.play",
139 limit: 100,
140 reverse: reverse,
141 ...(cursor ? { cursor } : {}),
142 });
143
144
145 // Process pages incrementally while fetching them
146 let shouldStop = false;
147 let endDateTime = new Date(endDate.value + 'T23:59:59.999');
148 while (true) {
149 // break
150 const records = (response.data.records as unknown as ListRecord<PlayValue>[]) ?? [];
151 totalCount += records.length;
152
153 for (const play of records) {
154 // spot-check if play is valid
155 if (!play.value || !play.value.trackName) {
156 continue;
157 }
158
159 if(endDateTime <= new Date(play.value.playedTime) ) {
160 console.log("End of the line");
161 shouldStop = true;
162 break;
163 }
164
165
166 // Aggregate by artist(s)
167 if (play.value?.artists) {
168 for (const artist of play.value?.artists) {
169 let alreadyPlayed = inner_artists.find(
170 (a) => a.name === artist.artistName,
171 );
172 if (!alreadyPlayed) {
173 inner_artists.push({
174 name: artist.artistName,
175 plays: 1,
176 });
177 } else {
178 alreadyPlayed.plays++;
179 }
180 }
181 } else if (play.value?.artistNames) {
182 // old version of lexicon
183 for (const arist of play.value?.artistNames) {
184 let alreadyPlayed = inner_artists.find(
185 (a) => a.name === arist,
186 );
187 if (!alreadyPlayed) {
188 inner_artists.push({name: arist, plays: 1});
189 } else {
190 alreadyPlayed.plays++;
191 }
192 }
193 }
194
195 // Aggregate by track
196 let alreadyPlayed = inner_tracks.find(
197 (a) => a.name === play.value.trackName,
198 );
199 if (!alreadyPlayed && play?.value) {
200 const primaryArtist = play.value.artists?.[0]?.artistName ?? play.value.artistNames?.[0] ?? "Unknown Artist";
201 inner_tracks.push({
202 name: play.value.trackName,
203 artist: primaryArtist,
204 plays: 1,
205 });
206 } else if (alreadyPlayed) {
207 alreadyPlayed.plays++;
208 }
209
210 // Aggregate by local day using playedTime
211 const key = localDayKey(play.value?.playedTime);
212 if (key) {
213 dayCountMap.set(key, (dayCountMap.get(key) ?? 0) + 1);
214 }
215 }
216
217 // update reactive values incrementally (top N)
218 artists.value = inner_artists
219 .sort((a, b) => b.plays - a.plays)
220 .slice(0, topLimit.value);
221 tracks.value = inner_tracks
222 .sort((a, b) => b.plays - a.plays)
223 .slice(0, topLimit.value);
224 totalSongs.value = totalCount;
225
226 // compute top N days
227 topDays.value = Array.from(dayCountMap.entries())
228 .map(([date, plays]) => ({date, plays}))
229 .sort((a, b) => b.plays - a.plays)
230 .slice(0, topLimit.value);
231
232 // update cursor and continue unless we've passed the start boundary
233 cursor = response.data.cursor ?? null;
234 if(shouldStop || cursor === null) {
235 break;
236 }
237
238 response = await agent.com.atproto.repo.listRecords({
239 repo: did,
240 collection: "fm.teal.alpha.feed.play",
241 limit: 100,
242 reverse: reverse,
243 ...(cursor ? { cursor } : {}),
244 });
245 }
246 } catch (error: unknown) {
247 errorMessage.value = error instanceof Error ? error.message : String(error);
248 throw error;
249 } finally {
250 loading.value = false;
251 }
252};
253</script>
254
255<template>
256 <div class="container mx-auto p-4 text-center">
257 <h1
258 class="text-5xl font-bold mb-2 bg-gradient-to-r from-teal-400 to-teal-600 text-transparent bg-clip-text"
259 >
260 teal.fm wrapped
261 </h1>
262 <p class="text-sm text-gray-500 mb-8">
263 Mostly not affiliated with teal.fm™
264 </p>
265 <form @submit.prevent="lookup">
266
267 <div class="join w-full justify-center">
268 <input
269 v-model="userHandle"
270 type="text"
271 placeholder="alice.bsky.social"
272 class="input input-bordered join-item w-1/2 max-w-xs"
273 />
274 <button
275 type="submit"
276 class="btn join-item bg-teal-500 hover:bg-teal-600 text-white"
277 >
278 That's a wrap
279 </button>
280 </div>
281
282 <!-- Advanced menu -->
283 <div class="mt-4">
284 <div class="collapse bg-base-200">
285 <input type="checkbox" />
286 <div class="collapse-title text-md font-medium">
287 Advanced options
288 </div>
289 <div class="collapse-content">
290 <div class="flex flex-col md:flex-row gap-4 items-center justify-center">
291 <label class="form-control w-full max-w-xs">
292 <div class="label">
293 <span class="label-text">Top records to show</span>
294 </div>
295 <input type="number" min="1" max="100" v-model.number="topLimit" class="input input-bordered w-full max-w-xs" />
296 </label>
297 <label class="form-control w-full max-w-xs">
298 <div class="label">
299 <span class="label-text">Start date</span>
300 </div>
301 <input type="date" v-model="startDate" class="input input-bordered w-full max-w-xs" />
302 </label>
303 <label class="form-control w-full max-w-xs">
304 <div class="label">
305 <span class="label-text">End date</span>
306 </div>
307 <input type="date" v-model="endDate" class="input input-bordered w-full max-w-xs" />
308 </label>
309 </div>
310 <p class="text-xs text-gray-500 mt-2">Date range start is start of day, end is end of day</p>
311 </div>
312 </div>
313 </div>
314
315 <span class="text-red-500" v-if="errorMessage">{{errorMessage}}</span>
316 </form>
317 <div class="w-full justify-center">
318 <span
319 v-if="loading"
320 class="loading loading-dots loading-lg mt-8"
321 ></span>
322 <div v-if="tracks.length > 0" class="mt-8">
323 <h2 class="text-2xl font-bold mb-4">
324 Top Songs out of {{ formatNumber(totalSongs) }}
325 </h2>
326 <div class="overflow-x-auto">
327 <table class="table w-full">
328 <thead>
329 <tr>
330 <th>Rank</th>
331 <th>Plays</th>
332 <th>Song</th>
333 </tr>
334 </thead>
335 <tbody>
336 <tr v-for="(track, idx) in tracks" :key="track.name">
337 <td>{{ idx + 1 }}.</td>
338 <td>{{ formatNumber(track.plays) }}</td>
339 <td>{{ track.name }} by {{ track.artist }}</td>
340 </tr>
341 </tbody>
342 </table>
343 </div>
344 </div>
345 <div v-if="artists.length > 0" class="mt-8">
346 <h2 class="text-2xl font-bold mb-4">Top Artists</h2>
347 <div class="overflow-x-auto">
348 <table class="table w-full">
349 <thead>
350 <tr>
351 <th>Rank</th>
352 <th>Plays</th>
353 <th>Artist</th>
354 </tr>
355 </thead>
356 <tbody>
357 <tr v-for="(artist, idx) in artists" :key="artist.name">
358 <td>{{ idx + 1 }}.</td>
359 <td>{{ formatNumber(artist.plays) }}</td>
360 <td>{{ artist.name }}</td>
361 </tr>
362 </tbody>
363 </table>
364 </div>
365 </div>
366 <div v-if="topDays.length > 0" class="mt-8">
367 <h2 class="text-2xl font-bold mb-4">Top Days (Most Songs Played)</h2>
368 <div class="overflow-x-auto">
369 <table class="table w-full">
370 <thead>
371 <tr>
372 <th>Rank</th>
373 <th>Plays</th>
374 <th>Date (yyyy-mm-dd)</th>
375 </tr>
376 </thead>
377 <tbody>
378 <tr v-for="(day, idx) in topDays" :key="day.date">
379 <td>{{ idx + 1 }}.</td>
380 <td>{{ formatNumber(day.plays) }}</td>
381 <td>{{ day.date }}</td>
382 </tr>
383 </tbody>
384 </table>
385 </div>
386 </div>
387 </div>
388 </div>
389</template>
390
391<style scoped>
392.container {
393 min-height: 50vh;
394 display: flex;
395 flex-direction: column;
396 justify-content: center;
397}
398</style>