alpha
Login
or
Join now
pds.dad
/
my-website
Star
0
Fork
0
Atom
Configure Feed
Issues
Pull Requests
Commits
Tags
Feed URL
Select the types of activity you want to include in your feed.
WIP: My personal website
Star
0
Fork
0
Atom
Configure Feed
Issues
Pull Requests
Commits
Tags
Feed URL
Select the types of activity you want to include in your feed.
Overview
Issues
Pulls
Pipelines
reading challenge display
author
Bailey Townsend
date
2 weeks ago
(Jun 8, 2026, 3:54 PM -0500)
commit
ec7f0275
ec7f02753e1fa7a90f19a44ef8143dc63f636b80
parent
4f68d73c
4f68d73c12a45f39812d297b257c1c007112e0e2
+98
-49
3 changed files
Expand all
Collapse all
Unified
Split
src
lib
components
BookHistory.svelte
server
atproto
books.ts
types.ts
+79
-43
src/lib/components/BookHistory.svelte
Reviewed
···
11
11
finishedBooks: Promise<FinishedBook[]>;
12
12
} = $props();
13
13
14
14
+
const READING_GOAL = 24;
15
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
21
+
}
22
22
+
23
23
+
type YearGroup = { year: string; books: FinishedBook[] };
24
24
+
25
25
+
function groupByYear(books: FinishedBook[]): YearGroup[] {
26
26
+
const buckets = new Map<string, FinishedBook[]>();
27
27
+
for (const book of books) {
28
28
+
const dateFinished = book.finishedAt ? new Date(book.finishedAt) : null;
29
29
+
const dateStarted = book.startedAt ? new Date(book.startedAt) : null;
30
30
+
const date = dateFinished ?? dateStarted ?? null;
31
31
+
const year = date && !Number.isNaN(date.getTime()) ? String(date.getFullYear()) : 'Unknown';
32
32
+
(buckets.get(year) ?? buckets.set(year, []).get(year)!).push(book);
33
33
+
}
34
34
+
return [...buckets.entries()]
35
35
+
.map(([year, books]) => ({ year, books }))
36
36
+
.sort((a, b) => {
37
37
+
if (a.year === 'Unknown') return 1;
38
38
+
if (b.year === 'Unknown') return -1;
39
39
+
return Number(b.year) - Number(a.year);
40
40
+
});
19
41
}
20
42
</script>
21
43
···
91
113
</div>
92
114
{:then finishedBooks}
93
115
{#if finishedBooks.length > 0}
94
94
-
<h3 class="mt-14 text-center font-urbanist text-xl font-semibold opacity-80 md:text-2xl">
95
95
-
Recently finished
96
96
-
</h3>
97
97
-
<div class="mt-6 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
98
98
-
{#each finishedBooks as book (book.bookUrl)}
99
99
-
<div class="card bg-base-100 shadow-sm transition duration-300 hover:-translate-y-1">
100
100
-
<div class="card-body flex-row items-start gap-4">
101
101
-
{#if book.cover}
102
102
-
<LazyLoadingImg
103
103
-
class="h-32 w-22 flex-none rounded-md shadow-sm"
104
104
-
src={book.cover}
105
105
-
alt={book.title}
106
106
-
/>
107
107
-
{/if}
108
108
-
<div class="flex min-w-0 flex-col">
109
109
-
<h4 class="font-urbanist text-lg font-black">{book.title}</h4>
110
110
-
<p class="font-urbanist text-sm font-medium opacity-60">{book.authors}</p>
111
111
-
{#if book.stars}
112
112
-
<p
113
113
-
class="mt-1 font-urbanist text-sm text-warning"
114
114
-
aria-label={`${book.stars} stars`}
115
115
-
>
116
116
-
{'★'.repeat(Math.round(book.stars))}<span class="opacity-30"
117
117
-
>{'★'.repeat(Math.max(0, 5 - Math.round(book.stars)))}</span
118
118
-
>
119
119
-
</p>
120
120
-
{/if}
121
121
-
{#if book.finishedAt}
122
122
-
<p class="mt-auto pt-2 font-urbanist text-xs opacity-40">
123
123
-
{finishedLabel(book.finishedAt)}
124
124
-
</p>
116
116
+
{#each groupByYear(finishedBooks) as group (group.year)}
117
117
+
<h3 class="mt-14 text-center font-urbanist text-2xl font-semibold opacity-80 md:text-3xl">
118
118
+
{group.year}
119
119
+
</h3>
120
120
+
{#if group.year !== 'Unknown'}
121
121
+
<div class="mx-auto mt-3 w-full max-w-md">
122
122
+
<progress
123
123
+
class="progress w-full progress-accent"
124
124
+
value={group.books.length}
125
125
+
max={READING_GOAL}
126
126
+
></progress>
127
127
+
<p class="mt-1 text-center font-urbanist text-sm opacity-50">
128
128
+
{group.books.length} of {READING_GOAL} books
129
129
+
</p>
130
130
+
</div>
131
131
+
{/if}
132
132
+
<div class="mt-6 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
133
133
+
{#each group.books as book (book.bookUrl)}
134
134
+
<div class="card bg-base-100 shadow-sm transition duration-300 hover:-translate-y-1">
135
135
+
<div class="card-body flex-row items-start gap-4">
136
136
+
{#if book.cover}
137
137
+
<LazyLoadingImg
138
138
+
class="h-32 w-22 flex-none rounded-md shadow-sm"
139
139
+
src={book.cover}
140
140
+
alt={book.title}
141
141
+
/>
125
142
{/if}
126
126
-
<div class="mt-2 card-actions">
127
127
-
<a
128
128
-
class="btn font-urbanist btn-ghost btn-xs"
129
129
-
href={book.bookUrl}
130
130
-
target="_blank"
131
131
-
rel="noopener noreferrer"
132
132
-
>
133
133
-
View
134
134
-
</a>
143
143
+
<div class="flex min-w-0 flex-col">
144
144
+
<h4 class="font-urbanist text-lg font-black">{book.title}</h4>
145
145
+
<p class="font-urbanist text-sm font-medium opacity-60">{book.authors}</p>
146
146
+
{#if book.stars}
147
147
+
<p
148
148
+
class="mt-1 font-urbanist text-sm text-warning"
149
149
+
aria-label={`${book.stars} stars`}
150
150
+
>
151
151
+
{'★'.repeat(Math.round(book.stars))}<span class="opacity-30"
152
152
+
>{'★'.repeat(Math.max(0, 5 - Math.round(book.stars)))}</span
153
153
+
>
154
154
+
</p>
155
155
+
{/if}
156
156
+
{#if book.finishedAt}
157
157
+
<p class="mt-auto pt-2 font-urbanist text-xs opacity-40">
158
158
+
{finishedLabel(book.finishedAt)}
159
159
+
</p>
160
160
+
{/if}
161
161
+
<div class="mt-2 card-actions">
162
162
+
<a
163
163
+
class="btn font-urbanist btn-ghost btn-xs"
164
164
+
href={book.bookUrl}
165
165
+
target="_blank"
166
166
+
rel="noopener noreferrer"
167
167
+
>
168
168
+
View
169
169
+
</a>
170
170
+
</div>
135
171
</div>
136
172
</div>
137
173
</div>
138
138
-
</div>
139
139
-
{/each}
140
140
-
</div>
174
174
+
{/each}
175
175
+
</div>
176
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
Reviewed
···
13
13
authors: string;
14
14
hiveId: string;
15
15
cover: string | null;
16
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
34
-
const res = await client.list(buzz.bookhive.book, {
35
35
-
repo: repoId(repo.did),
36
36
-
limit: 100
37
37
-
});
38
38
-
const records = res.records as ReadonlyArray<BookEntry>;
35
35
+
const records: BookEntry[] = [];
36
36
+
let cursor: string | undefined;
37
37
+
let pages = 0;
38
38
+
// Walk the cursor until the collection is exhausted (or we hit the safety
39
39
+
// cap). The full set is cached, so this only runs on a cold cache.
40
40
+
do {
41
41
+
const res = await client.list(buzz.bookhive.book, {
42
42
+
repo: repoId(repo.did),
43
43
+
limit: 100,
44
44
+
cursor
45
45
+
});
46
46
+
records.push(...(res.records as ReadonlyArray<BookEntry>));
47
47
+
cursor = res.cursor;
48
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
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
85
-
finishedAt: b.finishedAt ?? b.createdAt ?? null,
96
96
+
startedAt: b.startedAt ?? null,
97
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
Reviewed
···
57
57
bookUrl: string;
58
58
cover: string | null;
59
59
finishedAt: string | null;
60
60
+
startedAt: string | null;
60
61
stars: number | null;
61
62
};