See the best posts from any Bluesky account
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Cap loading page progress at BACKFILL_MAX_POSTS; show truncation notice on profile

The loading page denominator now reflects the actual backfill target
(min of profile post count and BACKFILL_MAX_POSTS) instead of the raw
post count, and the progress bar initializes at the current fetched
value on refresh rather than resetting to zero. A persisted `truncated`
flag on backfill_jobs records whether the cap was hit, so the profile
page can show a "posts since <date>" notice independent of the current
env var value. The progress bar is now a rounded div with a smooth
CSS transition instead of the native <progress> element.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+260 -158
+33 -8
app/controllers/profile_controller.ts
··· 142 142 143 143 // 5. Build the injected dependencies and run the loop. 144 144 const did = user.did 145 + const maxPosts = env.get('BACKFILL_MAX_POSTS', 10000) 145 146 const writer: SseWriter = { 146 147 write(chunk: string) { 147 148 if (!nodeRes.writableEnded) nodeRes.write(chunk) ··· 159 160 if (!r) return null 160 161 return { 161 162 fetchedPosts: r.fetchedPosts, 162 - totalPosts: r.totalPosts, 163 + totalPosts: r.totalPosts !== null ? Math.min(r.totalPosts, maxPosts) : null, 163 164 state: r.state, 164 165 error: r.error, 165 166 } ··· 225 226 // 4. Branch on user state 226 227 if (!user || user.backfilledAt === null) { 227 228 // Loading page — backfill not yet complete (trigger if not already running) 228 - let totalPosts: number 229 + let backfillInfo: { totalPosts: number; fetchedPosts: number } 229 230 try { 230 - totalPosts = await this.#ensureBackfillStarted(canonicalHandle) 231 + backfillInfo = await this.#ensureBackfillStarted(canonicalHandle) 231 232 } catch (err) { 232 233 if (err instanceof HandleNotFoundError) { 233 234 response.status(404) ··· 244 245 } 245 246 throw err 246 247 } 247 - return view.render('pages/profile/loading', { handle: canonicalHandle, totalPosts }) 248 + const maxPosts = env.get('BACKFILL_MAX_POSTS', 10000) 249 + const cappedTotal = Math.min(backfillInfo.totalPosts, maxPosts) 250 + return view.render('pages/profile/loading', { 251 + handle: canonicalHandle, 252 + totalPosts: cappedTotal, 253 + fetchedPosts: backfillInfo.fetchedPosts, 254 + }) 248 255 } 249 256 250 257 if (user.deletedAt !== null) { ··· 267 274 }) 268 275 } 269 276 277 + // 5b. Check if the backfill was truncated by BACKFILL_MAX_POSTS. 278 + // If so, fetch the oldest post date to show a "posts since" notice. 279 + let indexedSince: Date | null = null 280 + const backfillJob = await BackfillJobRow.find(user.did) 281 + if (backfillJob?.truncated) { 282 + try { 283 + indexedSince = await this.clickHouseStore.getOldestPostDate(user.did) 284 + } catch { 285 + // Non-critical — just skip the notice 286 + } 287 + } 288 + 270 289 // Add bsky.app URL to each post 271 290 const postsWithUrl = posts.map((p) => ({ 272 291 ...p, ··· 288 307 daysWindow, 289 308 posts: postsWithUrl, 290 309 canonicalUrl, 310 + indexedSince, 291 311 }) 292 312 } 293 313 ··· 303 323 * @throws HandleNotFoundError when the handle doesn't exist on Bluesky 304 324 * @throws BlueskyRateLimitedError when we're rate-limited 305 325 */ 306 - async #ensureBackfillStarted(canonicalHandle: string): Promise<number> { 326 + async #ensureBackfillStarted( 327 + canonicalHandle: string 328 + ): Promise<{ totalPosts: number; fetchedPosts: number }> { 307 329 // 1. Resolve handle → DID (network call) 308 330 const did = await this.handleResolver.resolveToDid(canonicalHandle) 309 331 ··· 345 367 await BackfillJob.dispatch({ did }) 346 368 } 347 369 348 - // 6. Return the authoritative total for this DID. If we lost the race, 370 + // 6. Return the authoritative totals for this DID. If we lost the race, 349 371 // read it back from the existing row so the loading view always gets 350 372 // a concrete denominator (never the one we just fetched-but-discarded). 351 373 if (weCreatedTheJob) { 352 - return postsCount 374 + return { totalPosts: postsCount, fetchedPosts: 0 } 353 375 } 354 376 const row = await BackfillJobRow.find(did) 355 - return row?.totalPosts ?? postsCount 377 + return { 378 + totalPosts: row?.totalPosts ?? postsCount, 379 + fetchedPosts: row?.fetchedPosts ?? 0, 380 + } 356 381 } 357 382 } 358 383
+1
app/jobs/backfill_job.ts
··· 124 124 jobRow.state = 'done' 125 125 jobRow.finishedAt = now 126 126 jobRow.fetchedPosts = fetchedCount 127 + jobRow.truncated = fetchedCount >= maxPosts 127 128 await jobRow.save() 128 129 } 129 130
+33
app/lib/clickhouse/store.ts
··· 192 192 })) 193 193 } 194 194 195 + /** 196 + * Returns the oldest post_created_at for an author, or null if they have no 197 + * non-deleted posts. Used to show "posts since <date>" when the backfill was 198 + * truncated by BACKFILL_MAX_POSTS. 199 + */ 200 + async getOldestPostDate(authorDid: string): Promise<Date | null> { 201 + const sql = ` 202 + SELECT min(post_created_at) AS oldest 203 + FROM post_snapshots FINAL 204 + WHERE post_author_did = {authorDid: String} 205 + AND is_deleted = 0` 206 + 207 + let resultSet 208 + try { 209 + resultSet = await this.client.query({ 210 + query: sql, 211 + format: 'JSONEachRow', 212 + query_params: { authorDid }, 213 + }) 214 + } catch (err) { 215 + throw new Error(`ClickHouseStore.getOldestPostDate failed for author ${authorDid}`, { 216 + cause: err, 217 + }) 218 + } 219 + 220 + const rows = await resultSet.json<{ oldest: string }>() 221 + if (rows.length === 0 || !rows[0].oldest) return null 222 + // ClickHouse returns '1970-01-01 00:00:00.000000' for min() over empty set 223 + const date = new Date(rows[0].oldest) 224 + if (date.getTime() === 0) return null 225 + return date 226 + } 227 + 195 228 // ------------------------------------------------------------------------- 196 229 // Write path — snapshots 197 230 // -------------------------------------------------------------------------
+3
app/models/backfill_job.ts
··· 20 20 declare fetchedPosts: number 21 21 22 22 @column() 23 + declare truncated: boolean 24 + 25 + @column() 23 26 declare state: 'running' | 'done' | 'failed' 24 27 25 28 @column()
+17
database/migrations/1775991741239_alter_backfill_jobs_table.ts
··· 1 + import { BaseSchema } from '@adonisjs/lucid/schema' 2 + 3 + export default class extends BaseSchema { 4 + protected tableName = 'backfill_jobs' 5 + 6 + async up() { 7 + this.schema.alterTable(this.tableName, (table) => { 8 + table.boolean('truncated').notNullable().defaultTo(false) 9 + }) 10 + } 11 + 12 + async down() { 13 + this.schema.alterTable(this.tableName, (table) => { 14 + table.dropColumn('truncated') 15 + }) 16 + } 17 + }
+2 -1
resources/js/backfill_progress.ts
··· 9 9 export interface BackfillProgressInitial { 10 10 handle: string 11 11 total: number 12 + fetched: number 12 13 } 13 14 14 15 export interface BackfillProgressDeps { ··· 34 35 35 36 return { 36 37 handle: initial.handle, 37 - fetched: 0, 38 + fetched: initial.fetched, 38 39 total: initial.total, 39 40 state: 'running', 40 41 error: null,
+14 -7
resources/views/pages/profile/loading.edge
··· 12 12 13 13 @slot('main') 14 14 <div 15 - x-data="backfillProgress({ handle: '{{ handle }}', total: {{ totalPosts }} })" 15 + x-data="backfillProgress({ handle: '{{ handle }}', total: {{ totalPosts }}, fetched: {{ fetchedPosts }} })" 16 16 class="py-16 text-center" 17 17 > 18 18 <div x-show="state !== 'failed'"> ··· 22 22 <p class="text-gray-500 max-w-[420px] mx-auto mb-6"> 23 23 This is a one-time index. It may take a few minutes for very active accounts. 24 24 </p> 25 - <progress 26 - max="{{ totalPosts }}" 27 - x-bind:value="fetched" 25 + <div 26 + role="progressbar" 27 + :aria-valuenow="fetched" 28 + aria-valuemin="0" 29 + aria-valuemax="{{ totalPosts }}" 28 30 aria-labelledby="backfill-title" 29 - class="w-80 max-w-[80%] h-3.5" 30 - ></progress> 31 + class="w-80 max-w-[80%] h-3.5 mx-auto rounded-full bg-gray-200 overflow-hidden" 32 + > 33 + <div 34 + class="h-full rounded-full bg-blue-600 transition-[width] duration-500 ease-out" 35 + x-bind:style="'width: ' + (total > 0 ? Math.min(fetched / total * 100, 100) : 0) + '%'" 36 + ></div> 37 + </div> 31 38 <p 32 39 aria-live="polite" 33 40 aria-atomic="true" 34 41 class="text-gray-500 mt-3 tabular-nums" 35 42 > 36 - Indexed <span x-text="fetched">0</span> of {{ totalPosts }} posts 43 + Indexed <span x-text="fetched">{{ fetchedPosts }}</span> of {{ totalPosts }} posts 37 44 </p> 38 45 </div> 39 46 <div
+142 -134
resources/views/pages/profile/show.edge
··· 2 2 @slot('title') 3 3 Top {{ kind === 'likes' ? 'liked' : 'reposted' }} posts of {{ '@' + handle }} — skystar 4 4 @endslot 5 - 5 + 6 6 @slot('main') 7 7 <div class="pt-8 pb-4"> 8 8 {{-- Profile header --}} 9 9 <div class="flex items-center gap-4 mb-6"> 10 - <div class="size-14 rounded-full bg-gray-300 shrink-0 flex items-center justify-center text-xl text-gray-400">@</div> 10 + <div class="size-14 rounded-full bg-gray-300 shrink-0 flex items-center justify-center text-xl text-gray-400">@</div> 11 11 <div> 12 12 @if(user.displayName) 13 - <div class="text-lg font-semibold">{{ user.displayName }}</div> 13 + <div class="text-lg font-semibold">{{ user.displayName }}</div> 14 14 @endif 15 - <div class="text-gray-600">{{ '@' + handle }}</div> 15 + <div class="text-gray-600">{{ '@' + handle }}</div> 16 16 </div> 17 17 </div> 18 - 18 + 19 19 {{-- Controls --}} 20 20 <div class="flex items-center justify-between mb-6 flex-wrap gap-6"> 21 21 {{-- Kind toggle --}} 22 22 <div class="flex gap-1"> 23 23 <a 24 - href="/profile/{{ handle }}/likes{{ daysWindow ? '?days=' + daysWindow : '' }}" 25 - class="px-3.5 py-1.5 rounded-full text-sm {{ kind === 'likes' ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-700' }}" 24 + href="/profile/{{ handle }}/likes{{ daysWindow ? '?days=' + daysWindow : '' }}" 25 + class="px-3.5 py-1.5 rounded-full text-sm {{ kind === 'likes' ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-700' }}" 26 26 >Most liked</a> 27 27 <a 28 - href="/profile/{{ handle }}/reposts{{ daysWindow ? '?days=' + daysWindow : '' }}" 29 - class="px-3.5 py-1.5 rounded-full text-sm {{ kind === 'reposts' ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-700' }}" 28 + href="/profile/{{ handle }}/reposts{{ daysWindow ? '?days=' + daysWindow : '' }}" 29 + class="px-3.5 py-1.5 rounded-full text-sm {{ kind === 'reposts' ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-700' }}" 30 30 >Most reposted</a> 31 31 </div> 32 - 32 + 33 33 {{-- Lens dropdown (simple links) --}} 34 34 <div class="flex gap-1"> 35 35 <a 36 - href="/profile/{{ handle }}/{{ kind }}" 37 - class="px-3.5 py-1.5 rounded-full text-sm {{ !daysWindow ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-700' }}" 36 + href="/profile/{{ handle }}/{{ kind }}" 37 + class="px-3.5 py-1.5 rounded-full text-sm {{ !daysWindow ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-700' }}" 38 38 >All time</a> 39 39 <a 40 - href="/profile/{{ handle }}/{{ kind }}?days=30" 41 - class="px-3.5 py-1.5 rounded-full text-sm {{ daysWindow === 30 ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-700' }}" 40 + href="/profile/{{ handle }}/{{ kind }}?days=30" 41 + class="px-3.5 py-1.5 rounded-full text-sm {{ daysWindow === 30 ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-700' }}" 42 42 >Last month</a> 43 43 </div> 44 44 </div> 45 - 45 + 46 + {{-- Truncation notice --}} 47 + @if(indexedSince) 48 + <p class="text-sm text-gray-500 mb-4"> 49 + This is a prolific poster! Showing posts since {{ indexedSince.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) }}. 50 + Older posts were not indexed. 51 + </p> 52 + @endif 53 + 46 54 {{-- Posts --}} 47 55 @if(posts.length === 0) 48 56 <p class="text-gray-400">{{ '@' + handle }} hasn't posted anything yet (or nothing in this window).</p> 49 - @else 50 - <ol class="list-none p-0 m-0"> 51 - @each((post, index) in posts) 52 - <li class="bg-white border border-neutral-200 rounded-lg p-4 mb-3"> 53 - <div class="flex justify-between items-start gap-3"> 54 - <div class="flex-1 min-w-0"> 55 - <p class="mb-3 whitespace-pre-wrap break-words">{{{ post.postTextSafe }}}</p> 56 - 57 - {{-- Embed block --}} 58 - @if(post.embed) 59 - @if(post.embed.type === 'images') 60 - @if(post.embed.items.length === 1) 61 - <div class="mb-3"> 62 - <a href="{{ post.embed.items[0].fullsize }}" target="_blank" rel="noopener" class="block"> 63 - <img 64 - src="{{ post.embed.items[0].thumb }}" 65 - alt="{{ post.embed.items[0].alt }}" 66 - loading="lazy" 67 - class="max-w-full max-h-[400px] rounded-lg object-cover" 68 - style="{{ post.embed.items[0].aspectRatio ? 'aspect-ratio: ' + post.embed.items[0].aspectRatio.width + ' / ' + post.embed.items[0].aspectRatio.height : '' }}" 69 - > 70 - </a> 71 - </div> 72 - @elseif(post.embed.items.length === 2) 73 - <div class="grid grid-cols-2 gap-1 mb-3"> 74 - @each(img in post.embed.items) 75 - <a href="{{ img.fullsize }}" target="_blank" rel="noopener" class="block"> 76 - <img 77 - src="{{ img.thumb }}" 78 - alt="{{ img.alt }}" 79 - loading="lazy" 80 - class="w-full h-[200px] rounded-md object-cover" 81 - > 82 - </a> 83 - @endeach 84 - </div> 85 - @elseif(post.embed.items.length === 3) 86 - <div class="grid grid-cols-2 grid-rows-2 gap-1 mb-3"> 87 - <a href="{{ post.embed.items[0].fullsize }}" target="_blank" rel="noopener" class="block row-span-2"> 88 - <img 89 - src="{{ post.embed.items[0].thumb }}" 90 - alt="{{ post.embed.items[0].alt }}" 91 - loading="lazy" 92 - class="w-full h-full rounded-md object-cover" 93 - > 94 - </a> 95 - <a href="{{ post.embed.items[1].fullsize }}" target="_blank" rel="noopener" class="block"> 96 - <img 97 - src="{{ post.embed.items[1].thumb }}" 98 - alt="{{ post.embed.items[1].alt }}" 99 - loading="lazy" 100 - class="w-full h-full rounded-md object-cover" 101 - > 102 - </a> 103 - <a href="{{ post.embed.items[2].fullsize }}" target="_blank" rel="noopener" class="block"> 104 - <img 105 - src="{{ post.embed.items[2].thumb }}" 106 - alt="{{ post.embed.items[2].alt }}" 107 - loading="lazy" 108 - class="w-full h-full rounded-md object-cover" 109 - > 110 - </a> 111 - </div> 112 - @elseif(post.embed.items.length >= 4) 113 - <div class="grid grid-cols-2 gap-1 mb-3"> 114 - @each(img in post.embed.items.slice(0, 4)) 115 - <a href="{{ img.fullsize }}" target="_blank" rel="noopener" class="block"> 116 - <img 117 - src="{{ img.thumb }}" 118 - alt="{{ img.alt }}" 119 - loading="lazy" 120 - class="w-full h-40 rounded-md object-cover" 121 - > 122 - </a> 123 - @endeach 124 - </div> 125 - @endif 126 - @elseif(post.embed.type === 'video') 127 - <div class="mb-3"> 128 - <a href="{{ post.bskyUrl }}" target="_blank" rel="noopener" class="block relative max-w-full"> 129 - <img 130 - src="{{ post.embed.thumbnail }}" 131 - alt="{{ post.embed.alt }}" 132 - loading="lazy" 133 - class="max-w-full max-h-[400px] rounded-lg object-cover" 134 - style="{{ post.embed.aspectRatio ? 'aspect-ratio: ' + post.embed.aspectRatio.width + ' / ' + post.embed.aspectRatio.height : '' }}" 135 - > 136 - <span class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-black/60 text-white size-12 rounded-full flex items-center justify-center text-xl">&#9654;</span> 137 - </a> 138 - </div> 139 - @elseif(post.embed.type === 'external') 140 - <div class="mb-3"> 141 - <a href="{{ post.embed.uri }}" target="_blank" rel="noopener" class="flex border border-neutral-200 rounded-lg overflow-hidden no-underline text-inherit"> 142 - @if(post.embed.thumb) 143 - <div class="shrink-0 w-[120px] min-h-[80px]"> 144 - <img 145 - src="{{ post.embed.thumb }}" 146 - alt="" 147 - loading="lazy" 148 - class="w-full h-full object-cover" 149 - > 150 - </div> 151 - @endif 152 - <div class="py-2.5 px-3 min-w-0 flex-1"> 153 - <div class="font-semibold text-sm mb-1 overflow-hidden text-ellipsis whitespace-nowrap">{{ post.embed.title }}</div> 154 - <div class="text-xs text-gray-400 line-clamp-2">{{ post.embed.description }}</div> 155 - </div> 156 - </a> 157 - </div> 158 - @endif 57 + @else 58 + <ol class="list-none p-0 m-0"> 59 + @each((post, index) in posts) 60 + <li class="bg-white border border-neutral-200 rounded-lg p-4 mb-3"> 61 + <div class="flex justify-between items-start gap-3"> 62 + <div class="flex-1 min-w-0"> 63 + <p class="mb-3 whitespace-pre-wrap break-words">{{{ post.postTextSafe }}}</p> 64 + 65 + {{-- Embed block --}} 66 + @if(post.embed) 67 + @if(post.embed.type === 'images') 68 + @if(post.embed.items.length === 1) 69 + <div class="mb-3"> 70 + <a href="{{ post.embed.items[0].fullsize }}" target="_blank" rel="noopener" class="block"> 71 + <img 72 + src="{{ post.embed.items[0].thumb }}" 73 + alt="{{ post.embed.items[0].alt }}" 74 + loading="lazy" 75 + class="max-w-full max-h-[400px] rounded-lg object-cover" 76 + style="{{ post.embed.items[0].aspectRatio ? 'aspect-ratio: ' + post.embed.items[0].aspectRatio.width + ' / ' + post.embed.items[0].aspectRatio.height : '' }}" 77 + > 78 + </a> 79 + </div> 80 + @elseif(post.embed.items.length === 2) 81 + <div class="grid grid-cols-2 gap-1 mb-3"> 82 + @each(img in post.embed.items) 83 + <a href="{{ img.fullsize }}" target="_blank" rel="noopener" class="block"> 84 + <img 85 + src="{{ img.thumb }}" 86 + alt="{{ img.alt }}" 87 + loading="lazy" 88 + class="w-full h-[200px] rounded-md object-cover" 89 + > 90 + </a> 91 + @endeach 92 + </div> 93 + @elseif(post.embed.items.length === 3) 94 + <div class="grid grid-cols-2 grid-rows-2 gap-1 mb-3"> 95 + <a href="{{ post.embed.items[0].fullsize }}" target="_blank" rel="noopener" class="block row-span-2"> 96 + <img 97 + src="{{ post.embed.items[0].thumb }}" 98 + alt="{{ post.embed.items[0].alt }}" 99 + loading="lazy" 100 + class="w-full h-full rounded-md object-cover" 101 + > 102 + </a> 103 + <a href="{{ post.embed.items[1].fullsize }}" target="_blank" rel="noopener" class="block"> 104 + <img 105 + src="{{ post.embed.items[1].thumb }}" 106 + alt="{{ post.embed.items[1].alt }}" 107 + loading="lazy" 108 + class="w-full h-full rounded-md object-cover" 109 + > 110 + </a> 111 + <a href="{{ post.embed.items[2].fullsize }}" target="_blank" rel="noopener" class="block"> 112 + <img 113 + src="{{ post.embed.items[2].thumb }}" 114 + alt="{{ post.embed.items[2].alt }}" 115 + loading="lazy" 116 + class="w-full h-full rounded-md object-cover" 117 + > 118 + </a> 119 + </div> 120 + @elseif(post.embed.items.length >= 4) 121 + <div class="grid grid-cols-2 gap-1 mb-3"> 122 + @each(img in post.embed.items.slice(0, 4)) 123 + <a href="{{ img.fullsize }}" target="_blank" rel="noopener" class="block"> 124 + <img 125 + src="{{ img.thumb }}" 126 + alt="{{ img.alt }}" 127 + loading="lazy" 128 + class="w-full h-40 rounded-md object-cover" 129 + > 130 + </a> 131 + @endeach 132 + </div> 133 + @endif 134 + @elseif(post.embed.type === 'video') 135 + <div class="mb-3"> 136 + <a href="{{ post.bskyUrl }}" target="_blank" rel="noopener" class="block relative max-w-full"> 137 + <img 138 + src="{{ post.embed.thumbnail }}" 139 + alt="{{ post.embed.alt }}" 140 + loading="lazy" 141 + class="max-w-full max-h-[400px] rounded-lg object-cover" 142 + style="{{ post.embed.aspectRatio ? 'aspect-ratio: ' + post.embed.aspectRatio.width + ' / ' + post.embed.aspectRatio.height : '' }}" 143 + > 144 + <span class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-black/60 text-white size-12 rounded-full flex items-center justify-center text-xl">&#9654;</span> 145 + </a> 146 + </div> 147 + @elseif(post.embed.type === 'external') 148 + <div class="mb-3"> 149 + <a href="{{ post.embed.uri }}" target="_blank" rel="noopener" class="flex border border-neutral-200 rounded-lg overflow-hidden no-underline text-inherit"> 150 + @if(post.embed.thumb) 151 + <div class="shrink-0 w-[120px] min-h-[80px]"> 152 + <img 153 + src="{{ post.embed.thumb }}" 154 + alt="" 155 + loading="lazy" 156 + class="w-full h-full object-cover" 157 + > 158 + </div> 159 159 @endif 160 - 161 - <div class="text-sm text-gray-400 flex gap-4 flex-wrap"> 162 - <span>♥ {{ post.likes }} likes</span> 163 - <span>♻ {{ post.reposts }} reposts</span> 160 + <div class="py-2.5 px-3 min-w-0 flex-1"> 161 + <div class="font-semibold text-sm mb-1 overflow-hidden text-ellipsis whitespace-nowrap">{{ post.embed.title }}</div> 162 + <div class="text-xs text-gray-400 line-clamp-2">{{ post.embed.description }}</div> 163 + </div> 164 + </a> 165 + </div> 166 + @endif 167 + @endif 168 + 169 + <div class="text-sm text-gray-400 flex gap-4 flex-wrap"> 170 + <span>♥ {{ post.likes }} likes</span> 171 + <span>♻ {{ post.reposts }} reposts</span> 164 172 <a href="{{ post.bskyUrl }}" target="_blank" rel="noopener" class="text-blue-600 hover:underline">view on Bluesky ↗</a> 173 + </div> 165 174 </div> 175 + <div class="text-xl font-bold text-gray-300 shrink-0">{{ index + 1 }}</div> 166 176 </div> 167 - <div class="text-xl font-bold text-gray-300 shrink-0">{{ index + 1 }}</div> 168 - </div> 169 - </li> 170 - @endeach 171 - </ol> 177 + </li> 178 + @endeach 179 + </ol> 172 180 @endif 173 181 </div> 174 182 @endslot
+15 -8
tests/unit/alpine/backfill_progress.spec.ts
··· 66 66 test.group('createBackfillProgress', () => { 67 67 test('initial state matches constructor args', ({ assert }) => { 68 68 const { deps } = makeDeps() 69 - const c = createBackfillProgress({ handle: 'alice.bsky.social', total: 42 }, deps) 69 + const c = createBackfillProgress({ handle: 'alice.bsky.social', total: 42, fetched: 0 }, deps) 70 70 assert.equal(c.handle, 'alice.bsky.social') 71 71 assert.equal(c.fetched, 0) 72 72 assert.equal(c.total, 42) ··· 74 74 assert.isNull(c.error) 75 75 }) 76 76 77 + test('initial fetched value is preserved from constructor', ({ assert }) => { 78 + const { deps } = makeDeps() 79 + const c = createBackfillProgress({ handle: 'alice.bsky.social', total: 100, fetched: 57 }, deps) 80 + assert.equal(c.fetched, 57) 81 + assert.equal(c.total, 100) 82 + }) 83 + 77 84 test('init() opens an EventSource at the right URL', ({ assert }) => { 78 85 const { deps } = makeDeps() 79 - const c = createBackfillProgress({ handle: 'bob.bsky.social', total: 10 }, deps) 86 + const c = createBackfillProgress({ handle: 'bob.bsky.social', total: 10, fetched: 0 }, deps) 80 87 c.init() 81 88 assert.lengthOf(FakeEventSource.instances, 1) 82 89 assert.equal(FakeEventSource.instances[0].url, '/profile/bob.bsky.social/backfill/stream') ··· 84 91 85 92 test('progress event updates fetched and total', ({ assert }) => { 86 93 const { deps } = makeDeps() 87 - const c = createBackfillProgress({ handle: 'carol.bsky.social', total: 10 }, deps) 94 + const c = createBackfillProgress({ handle: 'carol.bsky.social', total: 10, fetched: 0 }, deps) 88 95 c.init() 89 96 const es = FakeEventSource.instances[0] 90 97 ··· 100 107 101 108 test('done event closes the stream and navigates to /likes', ({ assert }) => { 102 109 const { deps, navigations } = makeDeps() 103 - const c = createBackfillProgress({ handle: 'dave.bsky.social', total: 5 }, deps) 110 + const c = createBackfillProgress({ handle: 'dave.bsky.social', total: 5, fetched: 0 }, deps) 104 111 c.init() 105 112 const es = FakeEventSource.instances[0] 106 113 ··· 111 118 112 119 test('failed event sets state=failed and error, closes the stream', ({ assert }) => { 113 120 const { deps, navigations } = makeDeps() 114 - const c = createBackfillProgress({ handle: 'eve.bsky.social', total: 5 }, deps) 121 + const c = createBackfillProgress({ handle: 'eve.bsky.social', total: 5, fetched: 0 }, deps) 115 122 c.init() 116 123 const es = FakeEventSource.instances[0] 117 124 ··· 124 131 125 132 test('failed event with missing error field falls back to a default', ({ assert }) => { 126 133 const { deps } = makeDeps() 127 - const c = createBackfillProgress({ handle: 'frank.bsky.social', total: 5 }, deps) 134 + const c = createBackfillProgress({ handle: 'frank.bsky.social', total: 5, fetched: 0 }, deps) 128 135 c.init() 129 136 const es = FakeEventSource.instances[0] 130 137 ··· 135 142 136 143 test('destroy() closes the EventSource', ({ assert }) => { 137 144 const { deps } = makeDeps() 138 - const c = createBackfillProgress({ handle: 'gina.bsky.social', total: 5 }, deps) 145 + const c = createBackfillProgress({ handle: 'gina.bsky.social', total: 5, fetched: 0 }, deps) 139 146 c.init() 140 147 const es = FakeEventSource.instances[0] 141 148 ··· 146 153 147 154 test('destroy() is a no-op if called before init()', ({ assert }) => { 148 155 const { deps } = makeDeps() 149 - const c = createBackfillProgress({ handle: 'hank.bsky.social', total: 5 }, deps) 156 + const c = createBackfillProgress({ handle: 'hank.bsky.social', total: 5, fetched: 0 }, deps) 150 157 c.destroy() 151 158 assert.lengthOf(FakeEventSource.instances, 0) 152 159 })