WIP: My personal website
0

Configure Feed

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

reading challenge display

+98 -49
+79 -43
src/lib/components/BookHistory.svelte
··· 11 11 finishedBooks: Promise<FinishedBook[]>; 12 12 } = $props(); 13 13 14 + const READING_GOAL = 24; 15 + 14 16 function finishedLabel(iso: string | null): string { 15 17 if (!iso) return ''; 16 18 const date = new Date(iso); 17 19 if (Number.isNaN(date.getTime())) return ''; 18 20 return date.toLocaleDateString('en', { month: 'short', year: 'numeric' }); 21 + } 22 + 23 + type YearGroup = { year: string; books: FinishedBook[] }; 24 + 25 + function groupByYear(books: FinishedBook[]): YearGroup[] { 26 + const buckets = new Map<string, FinishedBook[]>(); 27 + for (const book of books) { 28 + const dateFinished = book.finishedAt ? new Date(book.finishedAt) : null; 29 + const dateStarted = book.startedAt ? new Date(book.startedAt) : null; 30 + const date = dateFinished ?? dateStarted ?? null; 31 + const year = date && !Number.isNaN(date.getTime()) ? String(date.getFullYear()) : 'Unknown'; 32 + (buckets.get(year) ?? buckets.set(year, []).get(year)!).push(book); 33 + } 34 + return [...buckets.entries()] 35 + .map(([year, books]) => ({ year, books })) 36 + .sort((a, b) => { 37 + if (a.year === 'Unknown') return 1; 38 + if (b.year === 'Unknown') return -1; 39 + return Number(b.year) - Number(a.year); 40 + }); 19 41 } 20 42 </script> 21 43 ··· 91 113 </div> 92 114 {:then finishedBooks} 93 115 {#if finishedBooks.length > 0} 94 - <h3 class="mt-14 text-center font-urbanist text-xl font-semibold opacity-80 md:text-2xl"> 95 - Recently finished 96 - </h3> 97 - <div class="mt-6 grid gap-6 sm:grid-cols-2 lg:grid-cols-3"> 98 - {#each finishedBooks as book (book.bookUrl)} 99 - <div class="card bg-base-100 shadow-sm transition duration-300 hover:-translate-y-1"> 100 - <div class="card-body flex-row items-start gap-4"> 101 - {#if book.cover} 102 - <LazyLoadingImg 103 - class="h-32 w-22 flex-none rounded-md shadow-sm" 104 - src={book.cover} 105 - alt={book.title} 106 - /> 107 - {/if} 108 - <div class="flex min-w-0 flex-col"> 109 - <h4 class="font-urbanist text-lg font-black">{book.title}</h4> 110 - <p class="font-urbanist text-sm font-medium opacity-60">{book.authors}</p> 111 - {#if book.stars} 112 - <p 113 - class="mt-1 font-urbanist text-sm text-warning" 114 - aria-label={`${book.stars} stars`} 115 - > 116 - {'★'.repeat(Math.round(book.stars))}<span class="opacity-30" 117 - >{'★'.repeat(Math.max(0, 5 - Math.round(book.stars)))}</span 118 - > 119 - </p> 120 - {/if} 121 - {#if book.finishedAt} 122 - <p class="mt-auto pt-2 font-urbanist text-xs opacity-40"> 123 - {finishedLabel(book.finishedAt)} 124 - </p> 116 + {#each groupByYear(finishedBooks) as group (group.year)} 117 + <h3 class="mt-14 text-center font-urbanist text-2xl font-semibold opacity-80 md:text-3xl"> 118 + {group.year} 119 + </h3> 120 + {#if group.year !== 'Unknown'} 121 + <div class="mx-auto mt-3 w-full max-w-md"> 122 + <progress 123 + class="progress w-full progress-accent" 124 + value={group.books.length} 125 + max={READING_GOAL} 126 + ></progress> 127 + <p class="mt-1 text-center font-urbanist text-sm opacity-50"> 128 + {group.books.length} of {READING_GOAL} books 129 + </p> 130 + </div> 131 + {/if} 132 + <div class="mt-6 grid gap-6 sm:grid-cols-2 lg:grid-cols-3"> 133 + {#each group.books as book (book.bookUrl)} 134 + <div class="card bg-base-100 shadow-sm transition duration-300 hover:-translate-y-1"> 135 + <div class="card-body flex-row items-start gap-4"> 136 + {#if book.cover} 137 + <LazyLoadingImg 138 + class="h-32 w-22 flex-none rounded-md shadow-sm" 139 + src={book.cover} 140 + alt={book.title} 141 + /> 125 142 {/if} 126 - <div class="mt-2 card-actions"> 127 - <a 128 - class="btn font-urbanist btn-ghost btn-xs" 129 - href={book.bookUrl} 130 - target="_blank" 131 - rel="noopener noreferrer" 132 - > 133 - View 134 - </a> 143 + <div class="flex min-w-0 flex-col"> 144 + <h4 class="font-urbanist text-lg font-black">{book.title}</h4> 145 + <p class="font-urbanist text-sm font-medium opacity-60">{book.authors}</p> 146 + {#if book.stars} 147 + <p 148 + class="mt-1 font-urbanist text-sm text-warning" 149 + aria-label={`${book.stars} stars`} 150 + > 151 + {'★'.repeat(Math.round(book.stars))}<span class="opacity-30" 152 + >{'★'.repeat(Math.max(0, 5 - Math.round(book.stars)))}</span 153 + > 154 + </p> 155 + {/if} 156 + {#if book.finishedAt} 157 + <p class="mt-auto pt-2 font-urbanist text-xs opacity-40"> 158 + {finishedLabel(book.finishedAt)} 159 + </p> 160 + {/if} 161 + <div class="mt-2 card-actions"> 162 + <a 163 + class="btn font-urbanist btn-ghost btn-xs" 164 + href={book.bookUrl} 165 + target="_blank" 166 + rel="noopener noreferrer" 167 + > 168 + View 169 + </a> 170 + </div> 135 171 </div> 136 172 </div> 137 173 </div> 138 - </div> 139 - {/each} 140 - </div> 174 + {/each} 175 + </div> 176 + {/each} 141 177 {:else} 142 178 <p class="mt-10 text-center font-urbanist opacity-60">Nothing to show right now.</p> 143 179 {/if}
+18 -6
src/lib/server/atproto/books.ts
··· 13 13 authors: string; 14 14 hiveId: string; 15 15 cover: string | null; 16 + startedAt: string | null; 16 17 finishedAt: string | null; 17 18 createdAt: string | null; 18 19 stars: number | null; ··· 31 32 if (cached) return cached; 32 33 33 34 const client = createClient(repo.pds); 34 - const res = await client.list(buzz.bookhive.book, { 35 - repo: repoId(repo.did), 36 - limit: 100 37 - }); 38 - const records = res.records as ReadonlyArray<BookEntry>; 35 + const records: BookEntry[] = []; 36 + let cursor: string | undefined; 37 + let pages = 0; 38 + // Walk the cursor until the collection is exhausted (or we hit the safety 39 + // cap). The full set is cached, so this only runs on a cold cache. 40 + do { 41 + const res = await client.list(buzz.bookhive.book, { 42 + repo: repoId(repo.did), 43 + limit: 100, 44 + cursor 45 + }); 46 + records.push(...(res.records as ReadonlyArray<BookEntry>)); 47 + cursor = res.cursor; 48 + } while (cursor && ++pages < 50); 39 49 const books = records.map(({ value: v }) => ({ 40 50 status: v.status, 41 51 title: v.title, ··· 43 53 hiveId: v.hiveId, 44 54 cover: v.cover ? blobUrl(repo.pds, repo.did, v.cover) : null, 45 55 finishedAt: v.finishedAt ?? null, 56 + startedAt: v.startedAt ?? null, 46 57 createdAt: v.createdAt ?? null, 47 58 stars: v.stars ?? null 48 59 })); ··· 82 93 authors: b.authors, 83 94 bookUrl: `https://bookhive.buzz/books/${b.hiveId}`, 84 95 cover: b.cover, 85 - finishedAt: b.finishedAt ?? b.createdAt ?? null, 96 + startedAt: b.startedAt ?? null, 97 + finishedAt: b.finishedAt ?? null, 86 98 stars: b.stars ?? null 87 99 })) 88 100 .sort((a, b) => (b.finishedAt ?? '').localeCompare(a.finishedAt ?? ''));
+1
src/lib/types.ts
··· 57 57 bookUrl: string; 58 58 cover: string | null; 59 59 finishedAt: string | null; 60 + startedAt: string | null; 60 61 stars: number | null; 61 62 };