Shows some quick stats about your teal.fm records. Kind of like Spotify Wrapped

doneee

Changed files
+97 -14
src
components
+3 -3
README.md
··· 1 - # teal.fm wrapped 1 + # Teal Wrapped 2 2 3 - Quick hack would not even call this TypeScript. Just wanted to do a quick project with a UI to count songs and artists from my teal.fm records. 3 + An unofficial teal.fm stats viewer. View some quick stats about your fm.teal.alpha.feed.play records. 4 4 5 - Would not run or count on it. There will be errors 5 + [wrapped.baileytownsend.dev](https://wrapped.baileytownsend.dev/) 6 6 7 7 `bun install` 8 8 `bun run dev`
+1 -1
index.html
··· 4 4 <meta charset="UTF-8"> 5 5 <link rel="icon" href="/favicon.ico"> 6 6 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 7 - <title>Vite App</title> 7 + <title>An unofficial teal.fm wrapped</title> 8 8 </head> 9 9 <body> 10 10 <div id="app"></div>
+93 -10
src/components/LookUp.vue
··· 9 9 WellKnownHandleResolver, 10 10 } from "@atcute/identity-resolver"; 11 11 import {AtpAgent} from "@atproto/api"; 12 + import * as TID from "@atcute/tid"; 12 13 14 + //Should be using the lexicons to generate types... 13 15 // Types for fm.teal.alpha.feed.play 14 16 interface PlayArtist { 15 17 artistMbId?: string; ··· 21 23 // legacy support: earlier records may use artistNames 22 24 artistNames?: string[]; 23 25 trackName: string; 24 - playedTime?: string; // ISO string 26 + playedTime: string; // ISO string 25 27 releaseMbId?: string; 26 28 releaseName?: string; 27 29 recordingMbId?: string; ··· 59 61 const totalSongs = ref(0); 60 62 const errorMessage = ref<string | null>(null); 61 63 64 + // Advanced controls 65 + const topLimit = ref<number>(25); 66 + const startDate = ref<string | null>(null); // format: YYYY-MM-DD (local) 67 + const endDate = ref<string | null>(null); 68 + 62 69 const formatNumber = (n: number) => n.toLocaleString(); 63 70 64 71 // Returns YYYY-MM-DD in the browser's local time zone ··· 74 81 75 82 const lookup = async () => { 76 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 + 77 91 try { 78 92 const did = await handleResolver.resolve( 79 93 userHandle.value as `${string}.${string}`, ··· 94 108 const agent = new AtpAgent({ 95 109 service: endpoint, 96 110 }); 97 - let cursor: string | undefined = undefined; 111 + let cursor: string | null = null; 98 112 let totalCount = 0; 99 113 let inner_tracks: { name: string; artist: string; plays: number }[] = []; 100 114 let inner_artists: { name: string; plays: number }[] = []; 101 115 const dayCountMap = new Map<string, number>(); 102 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 + 103 136 let response = await agent.com.atproto.repo.listRecords({ 104 137 repo: did, 105 138 collection: "fm.teal.alpha.feed.play", 106 139 limit: 100, 140 + reverse: reverse, 107 141 ...(cursor ? { cursor } : {}), 108 142 }); 109 143 144 + 110 145 // Process pages incrementally while fetching them 146 + let shouldStop = false; 147 + let endDateTime = new Date(endDate.value + 'T23:59:59.999'); 111 148 while (true) { 149 + // break 112 150 const records = (response.data.records as unknown as ListRecord<PlayValue>[]) ?? []; 113 151 totalCount += records.length; 114 152 ··· 117 155 if (!play.value || !play.value.trackName) { 118 156 continue; 119 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 + 120 166 // Aggregate by artist(s) 121 167 if (play.value?.artists) { 122 168 for (const artist of play.value?.artists) { ··· 168 214 } 169 215 } 170 216 171 - // update reactive values incrementally (top 25) 217 + // update reactive values incrementally (top N) 172 218 artists.value = inner_artists 173 219 .sort((a, b) => b.plays - a.plays) 174 - .slice(0, 25); 220 + .slice(0, topLimit.value); 175 221 tracks.value = inner_tracks 176 222 .sort((a, b) => b.plays - a.plays) 177 - .slice(0, 25); 223 + .slice(0, topLimit.value); 178 224 totalSongs.value = totalCount; 179 225 180 - // compute top 25 days 226 + // compute top N days 181 227 topDays.value = Array.from(dayCountMap.entries()) 182 228 .map(([date, plays]) => ({date, plays})) 183 229 .sort((a, b) => b.plays - a.plays) 184 - .slice(0, 25); 230 + .slice(0, topLimit.value); 185 231 186 - cursor = response.data.cursor; 187 - if (!cursor) break; 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 + } 188 237 189 238 response = await agent.com.atproto.repo.listRecords({ 190 239 repo: did, 191 240 collection: "fm.teal.alpha.feed.play", 192 241 limit: 100, 242 + reverse: reverse, 193 243 ...(cursor ? { cursor } : {}), 194 244 }); 195 245 } ··· 207 257 <h1 208 258 class="text-5xl font-bold mb-2 bg-gradient-to-r from-teal-400 to-teal-600 text-transparent bg-clip-text" 209 259 > 210 - Teal Wrapped 260 + teal.fm wrapped 211 261 </h1> 212 262 <p class="text-sm text-gray-500 mb-8"> 213 263 Mostly not affiliated with teal.fm™ ··· 227 277 > 228 278 That's a wrap 229 279 </button> 280 + </div> 230 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> 231 313 </div> 314 + 232 315 <span class="text-red-500" v-if="errorMessage">{{errorMessage}}</span> 233 316 </form> 234 317 <div class="w-full justify-center">