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
4
fork

Configure Feed

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

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

Malpercio a5e4a7ae bdb0ea96

+310 -7
+238
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 + 353 591 describe("pagination", () => { 354 592 // Helper: create a fresh board for pagination tests 355 593 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 /**
+6 -2
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 dateSource = topic.lastReplyAt ?? topic.createdAt; 72 + const date = dateSource ? timeAgo(new Date(dateSource)) : "unknown"; 73 + const replyLabel = topic.replyCount === 1 ? "1 reply" : `${topic.replyCount} replies`; 70 74 return ( 71 75 <div class="topic-row"> 72 76 <a href={`/topics/${topic.id}`} class="topic-row__title"> ··· 75 79 <div class="topic-row__meta"> 76 80 <span>by {handle}</span> 77 81 <span>{date}</span> 78 - <span>0 replies</span> 82 + <span>{replyLabel}</span> 79 83 </div> 80 84 </div> 81 85 );