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): show reply count and last-reply date on board topic listing (#75)

* fix(web): show reply count and last-reply date on board topic listing

The topic listing on board pages always showed "0 replies" (hardcoded)
and used the topic's own createdAt for the date instead of the most
recent reply's timestamp.

- Add getReplyStats() helper: single GROUP BY query computing COUNT() and
MAX(createdAt) per rootPostId for a batch of topic IDs in one round-trip
- Enrich GET /api/boards/:id/topics response with replyCount and lastReplyAt
per topic; fail-open so a stats query failure degrades gracefully to 0/null
- Update TopicResponse interface and TopicRow in boards.tsx to consume the
new fields; date now reflects lastReplyAt ?? createdAt
- Add 5 integration tests covering zero replies, non-banned count, MAX date
accuracy, banned-reply exclusion, and per-topic independence

* 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)

* fix(web): update setupSuccessfulFetch type to include replyCount and lastReplyAt

The helper's topics array element type was not updated alongside makeTopicsResponse,
causing tsc to reject the new test cases with TS2353. Vitest strips types via esbuild
so it passed locally; tsc in CI caught the mismatch.

authored by

Malpercio and committed by
GitHub
0ed35d76 bdb0ea96

+396 -14
+271
apps/appview/src/routes/__tests__/boards.test.ts
··· 350 350 expect(data.topics).toHaveLength(2); // Still 2, not 3 351 351 }); 352 352 353 + describe("reply stats", () => { 354 + it("topics with no replies have replyCount 0 and lastReplyAt null", async () => { 355 + const res = await app.request(`/api/boards/${boardId}/topics`); 356 + expect(res.status).toBe(200); 357 + 358 + const data = await res.json(); 359 + expect(data.topics).toHaveLength(2); 360 + for (const topic of data.topics) { 361 + expect(topic.replyCount).toBe(0); 362 + expect(topic.lastReplyAt).toBeNull(); 363 + } 364 + }); 365 + 366 + it("replyCount reflects the number of non-banned replies", async () => { 367 + // Get the first topic's id so we can set rootPostId on replies 368 + const [topic1] = await ctx.db 369 + .select({ id: posts.id }) 370 + .from(posts) 371 + .where(eq(posts.rkey, "post1")) 372 + .limit(1); 373 + 374 + // Insert 2 replies to post1, 1 banned reply (should not be counted) 375 + await ctx.db.insert(posts).values([ 376 + { 377 + did: "did:plc:topicsuser", 378 + rkey: "reply1", 379 + cid: "bafyreply1", 380 + text: "First reply", 381 + boardId: boardId, 382 + boardUri: `at://${ctx.config.forumDid}/space.atbb.forum.board/board1`, 383 + rootPostId: topic1.id, 384 + parentPostId: topic1.id, 385 + createdAt: new Date("2026-02-13T12:00:00Z"), 386 + indexedAt: new Date(), 387 + }, 388 + { 389 + did: "did:plc:topicsuser", 390 + rkey: "reply2", 391 + cid: "bafyreply2", 392 + text: "Second reply", 393 + boardId: boardId, 394 + boardUri: `at://${ctx.config.forumDid}/space.atbb.forum.board/board1`, 395 + rootPostId: topic1.id, 396 + parentPostId: topic1.id, 397 + createdAt: new Date("2026-02-13T13:00:00Z"), 398 + indexedAt: new Date(), 399 + }, 400 + { 401 + did: "did:plc:topicsuser", 402 + rkey: "reply3-banned", 403 + cid: "bafyreply3", 404 + text: "Banned reply", 405 + boardId: boardId, 406 + boardUri: `at://${ctx.config.forumDid}/space.atbb.forum.board/board1`, 407 + rootPostId: topic1.id, 408 + parentPostId: topic1.id, 409 + bannedByMod: true, 410 + createdAt: new Date("2026-02-13T14:00:00Z"), 411 + indexedAt: new Date(), 412 + }, 413 + ]); 414 + 415 + const res = await app.request(`/api/boards/${boardId}/topics`); 416 + expect(res.status).toBe(200); 417 + 418 + const data = await res.json(); 419 + const topic1Row = data.topics.find((t: { text: string }) => t.text === "First topic"); 420 + expect(topic1Row.replyCount).toBe(2); // banned reply excluded 421 + }); 422 + 423 + it("lastReplyAt reflects the most recent non-banned reply's createdAt", async () => { 424 + const [topic1] = await ctx.db 425 + .select({ id: posts.id }) 426 + .from(posts) 427 + .where(eq(posts.rkey, "post1")) 428 + .limit(1); 429 + 430 + const lastReplyTime = new Date("2026-02-20T09:30:00Z"); 431 + 432 + await ctx.db.insert(posts).values([ 433 + { 434 + did: "did:plc:topicsuser", 435 + rkey: "reply-early", 436 + cid: "bafyreplyearly", 437 + text: "Earlier reply", 438 + boardId: boardId, 439 + boardUri: `at://${ctx.config.forumDid}/space.atbb.forum.board/board1`, 440 + rootPostId: topic1.id, 441 + parentPostId: topic1.id, 442 + createdAt: new Date("2026-02-15T08:00:00Z"), 443 + indexedAt: new Date(), 444 + }, 445 + { 446 + did: "did:plc:topicsuser", 447 + rkey: "reply-latest", 448 + cid: "bafyreplylast", 449 + text: "Latest reply", 450 + boardId: boardId, 451 + boardUri: `at://${ctx.config.forumDid}/space.atbb.forum.board/board1`, 452 + rootPostId: topic1.id, 453 + parentPostId: topic1.id, 454 + createdAt: lastReplyTime, 455 + indexedAt: new Date(), 456 + }, 457 + ]); 458 + 459 + const res = await app.request(`/api/boards/${boardId}/topics`); 460 + expect(res.status).toBe(200); 461 + 462 + const data = await res.json(); 463 + const topic1Row = data.topics.find((t: { text: string }) => t.text === "First topic"); 464 + expect(topic1Row.lastReplyAt).toBe(lastReplyTime.toISOString()); 465 + }); 466 + 467 + it("lastReplyAt ignores banned replies when computing latest", async () => { 468 + const [topic1] = await ctx.db 469 + .select({ id: posts.id }) 470 + .from(posts) 471 + .where(eq(posts.rkey, "post1")) 472 + .limit(1); 473 + 474 + const visibleReplyTime = new Date("2026-02-15T08:00:00Z"); 475 + 476 + await ctx.db.insert(posts).values([ 477 + { 478 + did: "did:plc:topicsuser", 479 + rkey: "reply-visible", 480 + cid: "bafyreplyvis", 481 + text: "Visible reply", 482 + boardId: boardId, 483 + boardUri: `at://${ctx.config.forumDid}/space.atbb.forum.board/board1`, 484 + rootPostId: topic1.id, 485 + parentPostId: topic1.id, 486 + createdAt: visibleReplyTime, 487 + indexedAt: new Date(), 488 + }, 489 + { 490 + did: "did:plc:topicsuser", 491 + rkey: "reply-banned-late", 492 + cid: "bafyreplybanned", 493 + text: "Later but banned reply", 494 + boardId: boardId, 495 + boardUri: `at://${ctx.config.forumDid}/space.atbb.forum.board/board1`, 496 + rootPostId: topic1.id, 497 + parentPostId: topic1.id, 498 + bannedByMod: true, 499 + createdAt: new Date("2026-02-20T09:00:00Z"), 500 + indexedAt: new Date(), 501 + }, 502 + ]); 503 + 504 + const res = await app.request(`/api/boards/${boardId}/topics`); 505 + expect(res.status).toBe(200); 506 + 507 + const data = await res.json(); 508 + const topic1Row = data.topics.find((t: { text: string }) => t.text === "First topic"); 509 + expect(topic1Row.lastReplyAt).toBe(visibleReplyTime.toISOString()); 510 + }); 511 + 512 + it("replyCount and lastReplyAt are independent per topic", async () => { 513 + const [topic1] = await ctx.db 514 + .select({ id: posts.id }) 515 + .from(posts) 516 + .where(eq(posts.rkey, "post1")) 517 + .limit(1); 518 + const [topic2] = await ctx.db 519 + .select({ id: posts.id }) 520 + .from(posts) 521 + .where(eq(posts.rkey, "post2")) 522 + .limit(1); 523 + 524 + const topic2ReplyTime = new Date("2026-02-21T10:00:00Z"); 525 + 526 + // 1 reply to topic1, 3 replies to topic2 527 + await ctx.db.insert(posts).values([ 528 + { 529 + did: "did:plc:topicsuser", 530 + rkey: "t1-reply", 531 + cid: "bafyt1reply", 532 + text: "Topic 1 reply", 533 + boardId: boardId, 534 + boardUri: `at://${ctx.config.forumDid}/space.atbb.forum.board/board1`, 535 + rootPostId: topic1.id, 536 + parentPostId: topic1.id, 537 + createdAt: new Date("2026-02-14T10:00:00Z"), 538 + indexedAt: new Date(), 539 + }, 540 + { 541 + did: "did:plc:topicsuser", 542 + rkey: "t2-reply1", 543 + cid: "bafyt2reply1", 544 + text: "Topic 2 first reply", 545 + boardId: boardId, 546 + boardUri: `at://${ctx.config.forumDid}/space.atbb.forum.board/board1`, 547 + rootPostId: topic2.id, 548 + parentPostId: topic2.id, 549 + createdAt: new Date("2026-02-14T11:00:00Z"), 550 + indexedAt: new Date(), 551 + }, 552 + { 553 + did: "did:plc:topicsuser", 554 + rkey: "t2-reply2", 555 + cid: "bafyt2reply2", 556 + text: "Topic 2 second reply", 557 + boardId: boardId, 558 + boardUri: `at://${ctx.config.forumDid}/space.atbb.forum.board/board1`, 559 + rootPostId: topic2.id, 560 + parentPostId: topic2.id, 561 + createdAt: new Date("2026-02-14T12:00:00Z"), 562 + indexedAt: new Date(), 563 + }, 564 + { 565 + did: "did:plc:topicsuser", 566 + rkey: "t2-reply3", 567 + cid: "bafyt2reply3", 568 + text: "Topic 2 third reply", 569 + boardId: boardId, 570 + boardUri: `at://${ctx.config.forumDid}/space.atbb.forum.board/board1`, 571 + rootPostId: topic2.id, 572 + parentPostId: topic2.id, 573 + createdAt: topic2ReplyTime, 574 + indexedAt: new Date(), 575 + }, 576 + ]); 577 + 578 + const res = await app.request(`/api/boards/${boardId}/topics`); 579 + expect(res.status).toBe(200); 580 + 581 + const data = await res.json(); 582 + const t1Row = data.topics.find((t: { text: string }) => t.text === "First topic"); 583 + const t2Row = data.topics.find((t: { text: string }) => t.text === "Second topic"); 584 + 585 + expect(t1Row.replyCount).toBe(1); 586 + expect(t2Row.replyCount).toBe(3); 587 + expect(t2Row.lastReplyAt).toBe(topic2ReplyTime.toISOString()); 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 + }); 622 + }); 623 + 353 624 describe("pagination", () => { 354 625 // Helper: create a fresh board for pagination tests 355 626 const createBoard = async (name: string) => {
+25 -4
apps/appview/src/routes/boards.ts
··· 2 2 import type { AppContext } from "../lib/app-context.js"; 3 3 import { boards, categories, posts, users } from "@atbb/db"; 4 4 import { asc, count, eq, and, desc, isNull } from "drizzle-orm"; 5 - import { serializeBoard, parseBigIntParam, serializePost } from "./helpers.js"; 5 + import { serializeBoard, parseBigIntParam, serializePost, serializeDate, getReplyStats } from "./helpers.js"; 6 6 import { handleRouteError } from "../lib/route-errors.js"; 7 + import { isProgrammingError } from "../lib/errors.js"; 7 8 8 9 /** 9 10 * Factory function that creates board routes with access to app context. ··· 108 109 109 110 const total = Number(countResult[0]?.count ?? 0); 110 111 112 + // Fetch reply counts and last-reply timestamps for the returned topics. 113 + // Fail-open: if the query fails, topics show 0 replies rather than an error page. 114 + const topicIds = topicResults.map((r) => r.post.id); 115 + let replyStatsMap = new Map<bigint, { replyCount: number; lastReplyAt: Date | null }>(); 116 + try { 117 + replyStatsMap = await getReplyStats(ctx.db, topicIds); 118 + } catch (error) { 119 + if (isProgrammingError(error)) throw error; 120 + ctx.logger.error("Failed to fetch reply stats for board topic listing - using defaults", { 121 + operation: "GET /api/boards/:id/topics - reply stats", 122 + boardId: id, 123 + error: error instanceof Error ? error.message : String(error), 124 + }); 125 + } 126 + 111 127 return c.json({ 112 - topics: topicResults.map(({ post, author }) => 113 - serializePost(post, author) 114 - ), 128 + topics: topicResults.map(({ post, author }) => { 129 + const stats = replyStatsMap.get(post.id) ?? { replyCount: 0, lastReplyAt: null }; 130 + return { 131 + ...serializePost(post, author), 132 + replyCount: stats.replyCount, 133 + lastReplyAt: serializeDate(stats.lastReplyAt), 134 + }; 135 + }), 115 136 total, 116 137 offset, 117 138 limit,
+41 -1
apps/appview/src/routes/helpers.ts
··· 1 1 import { users, forums, posts, categories, boards, modActions } from "@atbb/db"; 2 2 import type { Database } from "@atbb/db"; 3 3 import type { Logger } from "@atbb/logger"; 4 - import { eq, and, inArray, desc } from "drizzle-orm"; 4 + import { eq, and, inArray, desc, count, max } from "drizzle-orm"; 5 5 import { UnicodeString } from "@atproto/api"; 6 6 import { parseAtUri } from "../lib/at-uri.js"; 7 7 ··· 537 537 }); 538 538 throw error; // Let caller decide fail policy 539 539 } 540 + } 541 + 542 + /** 543 + * Query reply counts and last-reply timestamps for a list of topic post IDs. 544 + * Only non-moderated replies (bannedByMod = false) are counted. 545 + * Returns a Map from topic ID to { replyCount, lastReplyAt }. 546 + */ 547 + export async function getReplyStats( 548 + db: Database, 549 + topicIds: bigint[] 550 + ): Promise<Map<bigint, { replyCount: number; lastReplyAt: Date | null }>> { 551 + if (topicIds.length === 0) { 552 + return new Map(); 553 + } 554 + 555 + const rows = await db 556 + .select({ 557 + rootPostId: posts.rootPostId, 558 + replyCount: count(), 559 + lastReplyAt: max(posts.createdAt), 560 + }) 561 + .from(posts) 562 + .where( 563 + and( 564 + inArray(posts.rootPostId, topicIds), 565 + eq(posts.bannedByMod, false) 566 + ) 567 + ) 568 + .groupBy(posts.rootPostId); 569 + 570 + const result = new Map<bigint, { replyCount: number; lastReplyAt: Date | null }>(); 571 + for (const row of rows) { 572 + if (row.rootPostId !== null) { 573 + result.set(row.rootPostId, { 574 + replyCount: Number(row.replyCount), 575 + lastReplyAt: row.lastReplyAt ?? null, 576 + }); 577 + } 578 + } 579 + return result; 540 580 } 541 581 542 582 /**
+44 -5
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, ··· 122 126 * 3 (sequential): GET /api/categories/7 123 127 */ 124 128 function setupSuccessfulFetch(options: { 125 - topics?: Array<{ id?: string; did?: string; handle?: string | null; text?: string; createdAt?: string }>; 129 + topics?: Array<{ id?: string; did?: string; handle?: string | null; text?: string; createdAt?: string; replyCount?: number; lastReplyAt?: string | null }>; 126 130 total?: number; 127 131 offset?: number; 128 132 boardOverrides?: Partial<Record<string, unknown>>; ··· 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 () => {
+10 -3
apps/web/src/routes/boards.tsx
··· 52 52 parentPostId: string | null; 53 53 createdAt: string | null; 54 54 author: AuthorResponse | null; 55 + replyCount: number; 56 + lastReplyAt: string | null; 55 57 } 56 58 57 59 interface TopicsListResponse { ··· 66 68 function TopicRow({ topic }: { topic: TopicResponse }) { 67 69 const title = topic.title ?? topic.text.slice(0, 80); 68 70 const handle = topic.author?.handle ?? topic.author?.did ?? topic.did; 69 - const date = topic.createdAt ? timeAgo(new Date(topic.createdAt)) : "unknown"; 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"; 70 77 return ( 71 78 <div class="topic-row"> 72 79 <a href={`/topics/${topic.id}`} class="topic-row__title"> ··· 74 81 </a> 75 82 <div class="topic-row__meta"> 76 83 <span>by {handle}</span> 77 - <span>{date}</span> 78 - <span>0 replies</span> 84 + <span>{dateLabel}</span> 85 + <span>{replyLabel}</span> 79 86 </div> 80 87 </div> 81 88 );
+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,