WIP! A BB-style forum, on the ATmosphere! We're still working... we'll be back soon when we have something to show off!
node typescript hono htmx atproto

fix(web): address code review issues from PR #75

- Add fail-open error path test: mocks getReplyStats DB failure and
asserts 200 response with replyCount 0 / lastReplyAt null / logger.error called
- Fix UI attribution: when lastReplyAt is set, show "last reply X ago"
instead of raw date next to "by {author}" — disambiguates whose action
the timestamp refers to
- Update Bruno collection: add replyCount and lastReplyAt to docs example
and assert blocks for GET /api/boards/:id/topics
- Rebase: merge conflict in boards.ts imports resolved (handleReadError →
handleRouteError from PR #74 refactor)

+87 -8
+33
apps/appview/src/routes/__tests__/boards.test.ts
··· 586 586 expect(t2Row.replyCount).toBe(3); 587 587 expect(t2Row.lastReplyAt).toBe(topic2ReplyTime.toISOString()); 588 588 }); 589 + 590 + it("returns 200 with replyCount 0 and null lastReplyAt when getReplyStats query fails (fail-open)", async () => { 591 + const loggerSpy = vi.spyOn(ctx.logger, "error"); 592 + 593 + // Mock the 4th ctx.db.select() to throw — the first 3 are: 594 + // 1. board lookup, 2. count (Promise.all), 3. topics (Promise.all) 595 + // The 4th is the getReplyStats GROUP BY query. 596 + const originalSelect = ctx.db.select.bind(ctx.db); 597 + vi.spyOn(ctx.db, "select") 598 + .mockImplementationOnce(originalSelect) // board lookup 599 + .mockImplementationOnce(originalSelect) // count 600 + .mockImplementationOnce(originalSelect) // topics 601 + .mockImplementationOnce(() => { 602 + throw new Error("Database connection lost"); 603 + }); 604 + 605 + const res = await app.request(`/api/boards/${boardId}/topics`); 606 + expect(res.status).toBe(200); 607 + 608 + const data = await res.json(); 609 + expect(data.topics).toHaveLength(2); 610 + for (const topic of data.topics) { 611 + expect(topic.replyCount).toBe(0); 612 + expect(topic.lastReplyAt).toBeNull(); 613 + } 614 + 615 + expect(loggerSpy).toHaveBeenCalledWith( 616 + expect.stringContaining("Failed to fetch reply stats"), 617 + expect.objectContaining({ error: "Database connection lost" }) 618 + ); 619 + 620 + vi.restoreAllMocks(); 621 + }); 589 622 }); 590 623 591 624 describe("pagination", () => {
+43 -4
apps/web/src/routes/__tests__/boards.test.tsx
··· 83 83 handle?: string | null; 84 84 text?: string; 85 85 createdAt?: string; 86 + replyCount?: number; 87 + lastReplyAt?: string | null; 86 88 }> = [], 87 89 total = 0, 88 90 offset = 0, ··· 106 108 did: t.did ?? "did:plc:user", 107 109 handle: t.handle ?? "user.bsky.social", 108 110 }, 111 + replyCount: t.replyCount ?? 0, 112 + lastReplyAt: t.lastReplyAt ?? null, 109 113 })), 110 114 total, 111 115 offset, ··· 217 221 expect(html).toContain("just now"); 218 222 }); 219 223 220 - it("renders reply count placeholder as 0 replies", async () => { 224 + it("renders '0 replies' when topic has no replies", async () => { 225 + setupSuccessfulFetch({ 226 + topics: [{ id: "1", text: "A topic with no replies", replyCount: 0 }], 227 + total: 1, 228 + }); 229 + const routes = await loadBoardsRoutes(); 230 + const res = await routes.request("/boards/42"); 231 + const html = await res.text(); 232 + expect(html).toContain("0 replies"); 233 + }); 234 + 235 + it("renders '1 reply' (singular) when topic has one reply", async () => { 236 + setupSuccessfulFetch({ 237 + topics: [{ id: "1", text: "A topic", replyCount: 1 }], 238 + total: 1, 239 + }); 240 + const routes = await loadBoardsRoutes(); 241 + const res = await routes.request("/boards/42"); 242 + const html = await res.text(); 243 + expect(html).toContain("1 reply"); 244 + expect(html).not.toContain("1 replies"); 245 + }); 246 + 247 + it("shows 'last reply' label when topic has lastReplyAt", async () => { 248 + const lastReplyAt = new Date(Date.now() - 5000).toISOString(); // 5 seconds ago 249 + setupSuccessfulFetch({ 250 + topics: [{ id: "1", text: "Active topic", replyCount: 3, lastReplyAt }], 251 + total: 1, 252 + }); 253 + const routes = await loadBoardsRoutes(); 254 + const res = await routes.request("/boards/42"); 255 + const html = await res.text(); 256 + expect(html).toContain("last reply"); 257 + }); 258 + 259 + it("does not show 'last reply' label when topic has no replies", async () => { 260 + const recentDate = new Date(Date.now() - 5000).toISOString(); 221 261 setupSuccessfulFetch({ 222 - topics: [{ id: "1", text: "A topic with replies" }], 262 + topics: [{ id: "1", text: "New topic", replyCount: 0, createdAt: recentDate }], 223 263 total: 1, 224 264 }); 225 265 const routes = await loadBoardsRoutes(); 226 266 const res = await routes.request("/boards/42"); 227 267 const html = await res.text(); 228 - // replyCount may not exist in the API response — the UI should render "0 replies" or similar 229 - expect(html).toContain("repl"); // "replies" or "reply" 268 + expect(html).not.toContain("last reply"); 230 269 }); 231 270 232 271 it("shows empty state when no topics", async () => {
+6 -3
apps/web/src/routes/boards.tsx
··· 68 68 function TopicRow({ topic }: { topic: TopicResponse }) { 69 69 const title = topic.title ?? topic.text.slice(0, 80); 70 70 const handle = topic.author?.handle ?? topic.author?.did ?? topic.did; 71 - const dateSource = topic.lastReplyAt ?? topic.createdAt; 72 - const date = dateSource ? timeAgo(new Date(dateSource)) : "unknown"; 73 71 const replyLabel = topic.replyCount === 1 ? "1 reply" : `${topic.replyCount} replies`; 72 + const dateLabel = topic.lastReplyAt 73 + ? `last reply ${timeAgo(new Date(topic.lastReplyAt))}` 74 + : topic.createdAt 75 + ? timeAgo(new Date(topic.createdAt)) 76 + : "unknown"; 74 77 return ( 75 78 <div class="topic-row"> 76 79 <a href={`/topics/${topic.id}`} class="topic-row__title"> ··· 78 81 </a> 79 82 <div class="topic-row__meta"> 80 83 <span>by {handle}</span> 81 - <span>{date}</span> 84 + <span>{dateLabel}</span> 82 85 <span>{replyLabel}</span> 83 86 </div> 84 87 </div>
+5 -1
bruno/AppView API/Boards/Get Board Topics.bru
··· 16 16 assert { 17 17 res.status: eq 200 18 18 res.body.topics: isDefined 19 + res.body.topics[0].replyCount: isDefined 20 + res.body.topics[0].lastReplyAt: isDefined 19 21 } 20 22 21 23 docs { ··· 42 44 "boardId": "456", 43 45 "parentPostId": null, 44 46 "createdAt": "2026-02-13T00:00:00.000Z", 45 - "author": { "did": "...", "handle": "..." } | null 47 + "author": { "did": "...", "handle": "..." } | null, 48 + "replyCount": 3, 49 + "lastReplyAt": "2026-02-14T10:30:00.000Z" | null 46 50 } 47 51 ], 48 52 "total": 42,