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

feat(web): board view with HTMX load more pagination (ATB-28) (#45)

* docs: add try block granularity guidance to error handling standards

When a single try block covers multiple distinct operations, errors from
later steps get attributed to the first — misleading for operators
debugging production failures. Document the pattern of splitting try
blocks by failure semantics, with a concrete example from ATB-28.

* docs: add ATB-28 board view design doc

* docs: add ATB-28 board view implementation plan

* feat(web): add timeAgo utility for relative date formatting

* feat(web): add isNotFoundError helper for AppView 404 responses

* feat(appview): add GET /api/boards/:id endpoint

Adds single board lookup by ID to the boards router, with 400 for
invalid IDs, 404 for missing boards, and 500 with structured logging
for unexpected errors.

* feat(appview): add pagination to GET /api/boards/:id/topics

Adds optional ?offset and ?limit query params with defaults (0 and 25),
clamps limit to 100 max, and returns total/offset/limit in response
alongside topics. Count and topics queries run in parallel via Promise.all.

* feat(appview): add GET /api/categories/:id endpoint

* feat(web): implement board view with HTMX load more pagination

- Replace boards.tsx stub with full two-stage fetch implementation:
stage 1 fetches board metadata + topics in parallel, stage 2 fetches
category for breadcrumb navigation
- HTMX partial mode handles ?offset=N requests, returning HTML fragment
with topic rows and updated Load More button (or empty fragment on error)
- Full error handling: 400 for non-integer IDs, 404 for missing boards,
503 for network errors, 500 for server errors, re-throw for TypeError
- Add 19 comprehensive tests covering all routes, error cases, and HTMX
partial mode; remove 3 now-superseded stub tests from stubs.test.tsx

* fix(web): make board page Stage 2 category fetch non-fatal

Category name lookup for breadcrumb no longer returns a fatal error
response when it fails — the board content loaded in Stage 1 is shown
regardless, with the category segment omitted from the breadcrumb.

* docs(bruno): add Get Board and Get Category; update Get Board Topics with pagination

* docs: mark ATB-28 board view complete in project plan

* fix(web): remove unused unauthSession variable in boards test

* fix: add 500 error tests for GET /api/boards/:id and GET /api/categories/:id; log HTMX partial errors

authored by

Malpercio and committed by
GitHub
688521ce 94eb069a

+3036 -58
+31
CLAUDE.md
··· 491 491 - Return generic "try again later" for client errors (400) vs server errors (500) 492 492 - Fabricate data in catch blocks (return null or fail explicitly) 493 493 - Use empty catch blocks or catch without logging 494 + - Put two distinct operations in the same try block when they have different failure semantics — a failure in the second operation will report as failure of the first 495 + 496 + **Try Block Granularity:** 497 + 498 + When a try block covers multiple distinct operations in sequence, errors from later steps get reported with the wrong context. Split into separate try blocks when operations have meaningfully different failure messages: 499 + 500 + ```typescript 501 + // ❌ BAD: DB re-query failure reports "Failed to create category" even though 502 + // the PDS write already succeeded — misleading for operators debugging 503 + try { 504 + const result = await createCategory(...); // PDS write succeeded 505 + categoryUri = result.uri; 506 + const [cat] = await db.select()...; // DB re-query fails here 507 + } catch (error) { 508 + consola.error("Failed to create category:", ...); // Inaccurate! 509 + } 510 + 511 + // ✅ GOOD: each operation has its own try block and specific error message 512 + try { 513 + const result = await createCategory(...); 514 + categoryUri = result.uri; 515 + } catch (error) { 516 + consola.error("Failed to create category:", ...); 517 + } 518 + 519 + try { 520 + const [cat] = await db.select()...; 521 + } catch (error) { 522 + consola.error("Failed to look up category ID after creation:", ...); 523 + } 524 + ``` 494 525 495 526 **Programming Error Re-Throwing Pattern:** 496 527
+194
apps/appview/src/routes/__tests__/boards.test.ts
··· 321 321 const data = await res.json(); 322 322 expect(data.topics).toHaveLength(2); // Still 2, not 3 323 323 }); 324 + 325 + describe("pagination", () => { 326 + // Helper: create a fresh board for pagination tests 327 + const createBoard = async (name: string) => { 328 + const [testForum] = await ctx.db 329 + .select() 330 + .from(forums) 331 + .where(eq(forums.did, ctx.config.forumDid)) 332 + .limit(1); 333 + 334 + const rkey = `pag-cat-${Date.now()}-${Math.random().toString(36).slice(2)}`; 335 + const [cat] = await ctx.db.insert(categories).values({ 336 + did: ctx.config.forumDid, 337 + rkey, 338 + cid: `bafycat-${rkey}`, 339 + name: "Pagination Category", 340 + forumId: testForum.id, 341 + sortOrder: 99, 342 + createdAt: new Date(), 343 + indexedAt: new Date(), 344 + }).returning(); 345 + 346 + const boardRkey = `pag-board-${Date.now()}-${Math.random().toString(36).slice(2)}`; 347 + const [board] = await ctx.db.insert(boards).values({ 348 + did: ctx.config.forumDid, 349 + rkey: boardRkey, 350 + cid: `bafyboard-${boardRkey}`, 351 + name, 352 + sortOrder: 99, 353 + categoryId: cat.id, 354 + categoryUri: `at://${ctx.config.forumDid}/space.atbb.forum.category/${rkey}`, 355 + createdAt: new Date(), 356 + indexedAt: new Date(), 357 + }).returning(); 358 + 359 + return board; 360 + }; 361 + 362 + // Helper: insert N topics into a board 363 + const createTopics = async (board: { id: bigint; rkey: string }, count: number) => { 364 + const values = Array.from({ length: count }, (_, i) => ({ 365 + did: "did:plc:topicsuser", 366 + rkey: `pag-post-${board.rkey}-${i}-${Math.random().toString(36).slice(2)}`, 367 + cid: `bafypagpost-${board.rkey}-${i}`, 368 + text: `Pagination topic ${i + 1}`, 369 + boardId: board.id, 370 + boardUri: `at://${ctx.config.forumDid}/space.atbb.forum.board/${board.rkey}`, 371 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 372 + createdAt: new Date(Date.now() + i * 1000), 373 + indexedAt: new Date(), 374 + })); 375 + await ctx.db.insert(posts).values(values); 376 + }; 377 + 378 + it("response includes total, offset, and limit fields", async () => { 379 + const board = await createBoard("Pagination Board"); 380 + await createTopics(board, 3); 381 + const res = await app.request(`/api/boards/${board.id}/topics`); 382 + expect(res.status).toBe(200); 383 + const data = await res.json(); 384 + expect(data.total).toBe(3); 385 + expect(data.offset).toBe(0); 386 + expect(data.limit).toBe(25); 387 + }); 388 + 389 + it("applies offset and limit", async () => { 390 + const board = await createBoard("Offset Board"); 391 + await createTopics(board, 5); 392 + const res = await app.request( 393 + `/api/boards/${board.id}/topics?offset=2&limit=2` 394 + ); 395 + const data = await res.json(); 396 + expect(data.topics).toHaveLength(2); 397 + expect(data.offset).toBe(2); 398 + expect(data.limit).toBe(2); 399 + }); 400 + 401 + it("clamps limit to 100 maximum", async () => { 402 + const board = await createBoard("Clamp Board"); 403 + const res = await app.request( 404 + `/api/boards/${board.id}/topics?limit=999` 405 + ); 406 + const data = await res.json(); 407 + expect(data.limit).toBe(100); 408 + }); 409 + 410 + it("defaults offset to 0 when not provided", async () => { 411 + const board = await createBoard("Default Board"); 412 + const res = await app.request(`/api/boards/${board.id}/topics`); 413 + const data = await res.json(); 414 + expect(data.offset).toBe(0); 415 + }); 416 + 417 + it("returns empty topics array when offset exceeds total", async () => { 418 + const board = await createBoard("Empty Board"); 419 + await createTopics(board, 3); 420 + const res = await app.request( 421 + `/api/boards/${board.id}/topics?offset=1000` 422 + ); 423 + const data = await res.json(); 424 + expect(data.topics).toHaveLength(0); 425 + expect(data.total).toBe(3); // total unchanged 426 + }); 427 + }); 428 + }); 429 + 430 + describe("GET /api/boards/:id", () => { 431 + let ctx: TestContext; 432 + let app: Hono; 433 + let cleanedUp = false; 434 + 435 + const createBoard = async (name: string, description: string | null = null) => { 436 + const [testForum] = await ctx.db 437 + .select() 438 + .from(forums) 439 + .where(eq(forums.did, ctx.config.forumDid)) 440 + .limit(1); 441 + 442 + const [cat] = await ctx.db.insert(categories).values({ 443 + did: ctx.config.forumDid, 444 + rkey: `cat-${Date.now()}`, 445 + cid: `bafycat-${Date.now()}`, 446 + name: "Test Category", 447 + forumId: testForum.id, 448 + sortOrder: 1, 449 + createdAt: new Date(), 450 + indexedAt: new Date(), 451 + }).returning(); 452 + 453 + const [board] = await ctx.db.insert(boards).values({ 454 + did: ctx.config.forumDid, 455 + rkey: `board-${Date.now()}`, 456 + cid: `bafyboard-${Date.now()}`, 457 + name, 458 + description, 459 + sortOrder: 1, 460 + categoryId: cat.id, 461 + categoryUri: `at://${ctx.config.forumDid}/space.atbb.forum.category/${cat.rkey}`, 462 + createdAt: new Date(), 463 + indexedAt: new Date(), 464 + }).returning(); 465 + 466 + return board; 467 + }; 468 + 469 + beforeEach(async () => { 470 + ctx = await createTestContext(); 471 + app = new Hono().route("/api/boards", createBoardsRoutes(ctx)); 472 + cleanedUp = false; 473 + 474 + // Clear existing data 475 + await ctx.db.delete(posts); 476 + await ctx.db.delete(boards).where(eq(boards.did, ctx.config.forumDid)); 477 + await ctx.db.delete(categories).where(eq(categories.did, ctx.config.forumDid)); 478 + }); 479 + 480 + afterEach(async () => { 481 + if (!cleanedUp) { 482 + await ctx.cleanup(); 483 + } 484 + }); 485 + 486 + describe("GET /:id", () => { 487 + it("returns 200 with board data for valid board", async () => { 488 + const board = await createBoard("Test Board", "A board for testing"); 489 + const res = await app.request(`/api/boards/${board.id}`); 490 + expect(res.status).toBe(200); 491 + const data = await res.json(); 492 + expect(data.id).toBe(board.id.toString()); 493 + expect(data.name).toBe("Test Board"); 494 + expect(data.description).toBe("A board for testing"); 495 + }); 496 + 497 + it("returns 404 for unknown board ID", async () => { 498 + const res = await app.request("/api/boards/999999"); 499 + expect(res.status).toBe(404); 500 + }); 501 + 502 + it("returns 400 for non-integer board ID", async () => { 503 + const res = await app.request("/api/boards/not-a-number"); 504 + expect(res.status).toBe(400); 505 + }); 506 + 507 + it("returns 500 on database error", async () => { 508 + await ctx.cleanup(); 509 + cleanedUp = true; 510 + 511 + const res = await app.request("/api/boards/1"); 512 + expect(res.status).toBe(500); 513 + 514 + const data = await res.json(); 515 + expect(data.error).toBe("Failed to retrieve board. Please try again later."); 516 + }); 517 + }); 324 518 });
+67
apps/appview/src/routes/__tests__/categories.test.ts
··· 227 227 expect(data.error).toBe("Invalid category ID format"); 228 228 }); 229 229 }); 230 + 231 + describe("GET /:id", () => { 232 + let ctx: TestContext; 233 + let app: Hono; 234 + let cleanedUp = false; 235 + 236 + beforeEach(async () => { 237 + ctx = await createTestContext(); 238 + app = new Hono().route("/", createCategoriesRoutes(ctx)); 239 + cleanedUp = false; 240 + }); 241 + 242 + afterEach(async () => { 243 + if (!cleanedUp) { 244 + await ctx.cleanup(); 245 + } 246 + }); 247 + 248 + it("returns 200 with category data for valid category", async () => { 249 + const [testForum] = await ctx.db 250 + .select() 251 + .from(forums) 252 + .where(eq(forums.did, ctx.config.forumDid)) 253 + .limit(1); 254 + 255 + const [category] = await ctx.db 256 + .insert(categories) 257 + .values({ 258 + did: ctx.config.forumDid, 259 + rkey: "test-cat-get", 260 + cid: "bafycatget", 261 + name: "Test Category", 262 + forumId: testForum.id, 263 + sortOrder: 1, 264 + createdAt: new Date(), 265 + indexedAt: new Date(), 266 + }) 267 + .returning(); 268 + 269 + const res = await app.request(`/${category.id}`); 270 + expect(res.status).toBe(200); 271 + const data = await res.json(); 272 + expect(data.id).toBe(category.id.toString()); 273 + expect(data.name).toBe("Test Category"); 274 + }); 275 + 276 + it("returns 404 for unknown category ID", async () => { 277 + const res = await app.request("/999999"); 278 + expect(res.status).toBe(404); 279 + }); 280 + 281 + it("returns 400 for non-integer category ID", async () => { 282 + const res = await app.request("/not-a-number"); 283 + expect(res.status).toBe(400); 284 + }); 285 + 286 + it("returns 500 on database error", async () => { 287 + await ctx.cleanup(); 288 + cleanedUp = true; 289 + 290 + const res = await app.request("/1"); 291 + expect(res.status).toBe(500); 292 + 293 + const data = await res.json(); 294 + expect(data.error).toBe("Failed to retrieve category. Please try again later."); 295 + }); 296 + });
+63 -17
apps/appview/src/routes/boards.ts
··· 1 1 import { Hono } from "hono"; 2 2 import type { AppContext } from "../lib/app-context.js"; 3 3 import { boards, categories, posts, users } from "@atbb/db"; 4 - import { asc, eq, and, desc, isNull } from "drizzle-orm"; 4 + import { asc, count, eq, and, desc, isNull } from "drizzle-orm"; 5 5 import { serializeBoard, parseBigIntParam, serializePost } from "./helpers.js"; 6 6 7 7 /** ··· 35 35 ); 36 36 } 37 37 }) 38 + .get("/:id", async (c) => { 39 + const raw = c.req.param("id"); 40 + const id = parseBigIntParam(raw); 41 + if (id === null) { 42 + return c.json({ error: "Invalid board ID" }, 400); 43 + } 44 + 45 + try { 46 + const [board] = await ctx.db 47 + .select() 48 + .from(boards) 49 + .where(eq(boards.id, id)) 50 + .limit(1); 51 + 52 + if (!board) { 53 + return c.json({ error: "Board not found" }, 404); 54 + } 55 + 56 + return c.json(serializeBoard(board)); 57 + } catch (error) { 58 + console.error("Failed to fetch board by ID", { 59 + operation: "GET /api/boards/:id", 60 + boardId: raw, 61 + error: error instanceof Error ? error.message : String(error), 62 + }); 63 + return c.json({ error: "Failed to retrieve board. Please try again later." }, 500); 64 + } 65 + }) 38 66 .get("/:id/topics", async (c) => { 39 67 const { id } = c.req.param(); 40 68 ··· 43 71 return c.json({ error: "Invalid board ID format" }, 400); 44 72 } 45 73 74 + // Parse and validate pagination query params 75 + const offsetRaw = parseInt(c.req.query("offset") ?? "0", 10); 76 + const limitRaw = parseInt(c.req.query("limit") ?? "25", 10); 77 + const offset = isNaN(offsetRaw) || offsetRaw < 0 ? 0 : offsetRaw; 78 + const limit = isNaN(limitRaw) || limitRaw < 1 ? 25 : Math.min(limitRaw, 100); 79 + 46 80 try { 47 81 // Check if board exists 48 82 const [board] = await ctx.db ··· 55 89 return c.json({ error: "Board not found" }, 404); 56 90 } 57 91 58 - const topicResults = await ctx.db 59 - .select({ 60 - post: posts, 61 - author: users, 62 - }) 63 - .from(posts) 64 - .leftJoin(users, eq(posts.did, users.did)) 65 - .where( 66 - and( 67 - eq(posts.boardId, boardId), 68 - isNull(posts.rootPostId), // Topics only (not replies) 69 - eq(posts.deleted, false) 70 - ) 71 - ) 72 - .orderBy(desc(posts.createdAt)) 73 - .limit(1000); // Defensive limit 92 + const topicFilter = and( 93 + eq(posts.boardId, boardId), 94 + isNull(posts.rootPostId), // Topics only (not replies) 95 + eq(posts.deleted, false) 96 + ); 97 + 98 + const [countResult, topicResults] = await Promise.all([ 99 + ctx.db 100 + .select({ count: count() }) 101 + .from(posts) 102 + .where(topicFilter), 103 + ctx.db 104 + .select({ 105 + post: posts, 106 + author: users, 107 + }) 108 + .from(posts) 109 + .leftJoin(users, eq(posts.did, users.did)) 110 + .where(topicFilter) 111 + .orderBy(desc(posts.createdAt)) 112 + .limit(limit) 113 + .offset(offset), 114 + ]); 115 + 116 + const total = Number(countResult[0]?.count ?? 0); 74 117 75 118 return c.json({ 76 119 topics: topicResults.map(({ post, author }) => 77 120 serializePost(post, author) 78 121 ), 122 + total, 123 + offset, 124 + limit, 79 125 }); 80 126 } catch (error) { 81 127 console.error("Failed to query board topics", {
+28
apps/appview/src/routes/categories.ts
··· 40 40 ); 41 41 } 42 42 }) 43 + .get("/:id", async (c) => { 44 + const raw = c.req.param("id"); 45 + const id = parseBigIntParam(raw); 46 + if (id === null) { 47 + return c.json({ error: "Invalid category ID" }, 400); 48 + } 49 + 50 + try { 51 + const [category] = await ctx.db 52 + .select() 53 + .from(categories) 54 + .where(eq(categories.id, id)) 55 + .limit(1); 56 + 57 + if (!category) { 58 + return c.json({ error: "Category not found" }, 404); 59 + } 60 + 61 + return c.json(serializeCategory(category)); 62 + } catch (error) { 63 + console.error("Failed to fetch category by ID", { 64 + operation: "GET /api/categories/:id", 65 + categoryId: raw, 66 + error: error instanceof Error ? error.message : String(error), 67 + }); 68 + return c.json({ error: "Failed to retrieve category. Please try again later." }, 500); 69 + } 70 + }) 43 71 .get("/:id/boards", async (c) => { 44 72 const { id } = c.req.param(); 45 73
+25
apps/web/src/lib/__tests__/errors.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { isNotFoundError } from "../errors.js"; 3 + 4 + describe("isNotFoundError", () => { 5 + it("returns true for AppView 404 error", () => { 6 + const error = new Error("AppView API error: 404 Not Found"); 7 + expect(isNotFoundError(error)).toBe(true); 8 + }); 9 + 10 + it("returns false for AppView 500 error", () => { 11 + const error = new Error("AppView API error: 500 Internal Server Error"); 12 + expect(isNotFoundError(error)).toBe(false); 13 + }); 14 + 15 + it("returns false for network error", () => { 16 + const error = new Error("AppView network error: fetch failed"); 17 + expect(isNotFoundError(error)).toBe(false); 18 + }); 19 + 20 + it("returns false for non-Error values", () => { 21 + expect(isNotFoundError("not found")).toBe(false); 22 + expect(isNotFoundError(null)).toBe(false); 23 + expect(isNotFoundError(404)).toBe(false); 24 + }); 25 + });
+53
apps/web/src/lib/__tests__/time.test.ts
··· 1 + import { describe, it, expect, vi, afterEach } from "vitest"; 2 + import { timeAgo } from "../time.js"; 3 + 4 + describe("timeAgo", () => { 5 + afterEach(() => { 6 + vi.useRealTimers(); 7 + }); 8 + 9 + function setNow(iso: string) { 10 + vi.useFakeTimers(); 11 + vi.setSystemTime(new Date(iso)); 12 + } 13 + 14 + it("returns 'just now' for under 60 seconds", () => { 15 + setNow("2026-01-01T12:00:30Z"); 16 + expect(timeAgo(new Date("2026-01-01T12:00:00Z"))).toBe("just now"); 17 + }); 18 + 19 + it("returns '1 minute ago' (singular)", () => { 20 + setNow("2026-01-01T12:01:00Z"); 21 + expect(timeAgo(new Date("2026-01-01T12:00:00Z"))).toBe("1 minute ago"); 22 + }); 23 + 24 + it("returns 'N minutes ago' for under 60 minutes", () => { 25 + setNow("2026-01-01T12:05:00Z"); 26 + expect(timeAgo(new Date("2026-01-01T12:00:00Z"))).toBe("5 minutes ago"); 27 + }); 28 + 29 + it("returns '1 hour ago' (singular)", () => { 30 + setNow("2026-01-01T13:00:00Z"); 31 + expect(timeAgo(new Date("2026-01-01T12:00:00Z"))).toBe("1 hour ago"); 32 + }); 33 + 34 + it("returns 'N hours ago' for under 24 hours", () => { 35 + setNow("2026-01-01T15:00:00Z"); 36 + expect(timeAgo(new Date("2026-01-01T12:00:00Z"))).toBe("3 hours ago"); 37 + }); 38 + 39 + it("returns '1 day ago' (singular)", () => { 40 + setNow("2026-01-02T12:00:00Z"); 41 + expect(timeAgo(new Date("2026-01-01T12:00:00Z"))).toBe("1 day ago"); 42 + }); 43 + 44 + it("returns 'N days ago' for under 30 days", () => { 45 + setNow("2026-01-08T12:00:00Z"); 46 + expect(timeAgo(new Date("2026-01-01T12:00:00Z"))).toBe("7 days ago"); 47 + }); 48 + 49 + it("returns ISO date (YYYY-MM-DD) for 30+ days old", () => { 50 + setNow("2026-02-01T12:00:00Z"); 51 + expect(timeAgo(new Date("2026-01-01T12:00:00Z"))).toBe("2026-01-01"); 52 + }); 53 + });
+14
apps/web/src/lib/errors.ts
··· 24 24 error.message.startsWith("AppView network error:") 25 25 ); 26 26 } 27 + 28 + /** 29 + * Returns true if the error is an AppView 404 Not Found response. 30 + * Callers should return a 404 HTML page to the user. 31 + * 32 + * fetchApi() throws with this prefix for non-ok HTTP responses: 33 + * "AppView API error: 404 Not Found" 34 + */ 35 + export function isNotFoundError(error: unknown): boolean { 36 + return ( 37 + error instanceof Error && 38 + error.message.startsWith("AppView API error: 404") 39 + ); 40 + }
+21
apps/web/src/lib/time.ts
··· 1 + /** 2 + * Returns a human-readable relative time string for a given date. 3 + * Examples: "just now", "5 minutes ago", "2 hours ago", "3 days ago", "2026-01-01" 4 + */ 5 + export function timeAgo(date: Date): string { 6 + const diffMs = Date.now() - date.getTime(); 7 + const diffSecs = Math.floor(diffMs / 1000); 8 + 9 + if (diffSecs < 60) return "just now"; 10 + 11 + const diffMins = Math.floor(diffSecs / 60); 12 + if (diffMins < 60) return `${diffMins} minute${diffMins === 1 ? "" : "s"} ago`; 13 + 14 + const diffHours = Math.floor(diffMins / 60); 15 + if (diffHours < 24) return `${diffHours} hour${diffHours === 1 ? "" : "s"} ago`; 16 + 17 + const diffDays = Math.floor(diffHours / 24); 18 + if (diffDays < 30) return `${diffDays} day${diffDays === 1 ? "" : "s"} ago`; 19 + 20 + return date.toISOString().split("T")[0]; 21 + }
+395
apps/web/src/routes/__tests__/boards.test.tsx
··· 1 + import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 2 + 3 + const mockFetch = vi.fn(); 4 + 5 + describe("createBoardsRoutes", () => { 6 + beforeEach(() => { 7 + vi.stubGlobal("fetch", mockFetch); 8 + vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 9 + vi.resetModules(); 10 + mockFetch.mockResolvedValue({ ok: false, status: 401 }); 11 + }); 12 + 13 + afterEach(() => { 14 + vi.unstubAllGlobals(); 15 + vi.unstubAllEnvs(); 16 + mockFetch.mockReset(); 17 + }); 18 + 19 + // Mock response helpers 20 + function mockResponse(body: unknown, ok = true, status = 200) { 21 + return { 22 + ok, 23 + status, 24 + statusText: ok ? "OK" : "Error", 25 + json: () => Promise.resolve(body), 26 + }; 27 + } 28 + 29 + const authSession = { 30 + ok: true, 31 + json: () => 32 + Promise.resolve({ 33 + authenticated: true, 34 + did: "did:plc:abc", 35 + handle: "alice.bsky.social", 36 + }), 37 + }; 38 + 39 + function makeBoardResponse(overrides: Partial<Record<string, unknown>> = {}) { 40 + return { 41 + ok: true, 42 + json: () => 43 + Promise.resolve({ 44 + id: "42", 45 + did: "did:plc:forum", 46 + name: "General Discussion", 47 + description: "Talk about anything", 48 + slug: null, 49 + sortOrder: 1, 50 + categoryId: "7", 51 + categoryUri: "at://did:plc:forum/space.atbb.forum.category/7", 52 + createdAt: "2025-01-01T00:00:00.000Z", 53 + indexedAt: "2025-01-01T00:00:00.000Z", 54 + ...overrides, 55 + }), 56 + }; 57 + } 58 + 59 + function makeCategoryResponse(overrides: Partial<Record<string, unknown>> = {}) { 60 + return { 61 + ok: true, 62 + json: () => 63 + Promise.resolve({ 64 + id: "7", 65 + did: "did:plc:forum", 66 + name: "Main Category", 67 + description: null, 68 + slug: null, 69 + sortOrder: 1, 70 + forumId: "1", 71 + createdAt: "2025-01-01T00:00:00.000Z", 72 + indexedAt: "2025-01-01T00:00:00.000Z", 73 + ...overrides, 74 + }), 75 + }; 76 + } 77 + 78 + function makeTopicsResponse( 79 + topics: Array<{ 80 + id?: string; 81 + did?: string; 82 + handle?: string | null; 83 + text?: string; 84 + createdAt?: string; 85 + }> = [], 86 + total = 0, 87 + offset = 0, 88 + limit = 25 89 + ) { 90 + return { 91 + ok: true, 92 + json: () => 93 + Promise.resolve({ 94 + topics: topics.map((t) => ({ 95 + id: t.id ?? "1", 96 + did: t.did ?? "did:plc:user", 97 + rkey: "tid", 98 + text: t.text ?? "Sample topic", 99 + forumUri: null, 100 + boardUri: null, 101 + boardId: "42", 102 + parentPostId: null, 103 + createdAt: t.createdAt ?? "2025-01-01T00:00:00.000Z", 104 + author: { 105 + did: t.did ?? "did:plc:user", 106 + handle: t.handle ?? "user.bsky.social", 107 + }, 108 + })), 109 + total, 110 + offset, 111 + limit, 112 + }), 113 + }; 114 + } 115 + 116 + /** 117 + * Sets up mock fetch for a full-page unauthenticated board view request. 118 + * No session call since no atbb_session cookie is sent. 119 + * Fetch order: 120 + * 1 & 2 (parallel): GET /api/boards/42 + GET /api/boards/42/topics 121 + * 3 (sequential): GET /api/categories/7 122 + */ 123 + function setupSuccessfulFetch(options: { 124 + topics?: Array<{ id?: string; did?: string; handle?: string | null; text?: string; createdAt?: string }>; 125 + total?: number; 126 + offset?: number; 127 + boardOverrides?: Partial<Record<string, unknown>>; 128 + categoryOverrides?: Partial<Record<string, unknown>>; 129 + } = {}) { 130 + const { topics = [], total = 0, offset = 0 } = options; 131 + // Parallel stage 1: board + topics (order may vary) 132 + mockFetch.mockResolvedValueOnce(makeBoardResponse(options.boardOverrides ?? {})); 133 + mockFetch.mockResolvedValueOnce(makeTopicsResponse(topics, total, offset)); 134 + // Sequential stage 2: category 135 + mockFetch.mockResolvedValueOnce(makeCategoryResponse(options.categoryOverrides ?? {})); 136 + } 137 + 138 + async function loadBoardsRoutes() { 139 + const { createBoardsRoutes } = await import("../boards.js"); 140 + return createBoardsRoutes("http://localhost:3000"); 141 + } 142 + 143 + // ────────────────────────────────────────────────────────────────────────── 144 + // Full-page tests 145 + // ────────────────────────────────────────────────────────────────────────── 146 + 147 + it("returns 400 for non-integer board ID", async () => { 148 + const routes = await loadBoardsRoutes(); 149 + const res = await routes.request("/boards/not-a-number"); 150 + expect(res.status).toBe(400); 151 + }); 152 + 153 + it("renders board name as page title", async () => { 154 + setupSuccessfulFetch(); 155 + const routes = await loadBoardsRoutes(); 156 + const res = await routes.request("/boards/42"); 157 + expect(res.status).toBe(200); 158 + const html = await res.text(); 159 + expect(html).toContain("General Discussion — atBB Forum"); 160 + }); 161 + 162 + it("renders board description", async () => { 163 + setupSuccessfulFetch(); 164 + const routes = await loadBoardsRoutes(); 165 + const res = await routes.request("/boards/42"); 166 + const html = await res.text(); 167 + expect(html).toContain("Talk about anything"); 168 + }); 169 + 170 + it("renders breadcrumb with category name and board name", async () => { 171 + setupSuccessfulFetch(); 172 + const routes = await loadBoardsRoutes(); 173 + const res = await routes.request("/boards/42"); 174 + const html = await res.text(); 175 + expect(html).toContain("Main Category"); 176 + expect(html).toContain("General Discussion"); 177 + // Breadcrumb nav present 178 + expect(html).toContain("breadcrumb"); 179 + }); 180 + 181 + it("renders topic list with truncated title", async () => { 182 + const longText = "A".repeat(100); 183 + setupSuccessfulFetch({ 184 + topics: [{ id: "1", text: longText }], 185 + total: 1, 186 + }); 187 + const routes = await loadBoardsRoutes(); 188 + const res = await routes.request("/boards/42"); 189 + const html = await res.text(); 190 + // Title should be truncated to 80 chars 191 + expect(html).toContain("A".repeat(80)); 192 + expect(html).not.toContain("A".repeat(81)); 193 + }); 194 + 195 + it("renders topic author handle", async () => { 196 + setupSuccessfulFetch({ 197 + topics: [{ id: "1", handle: "bob.bsky.social", text: "Hello world" }], 198 + total: 1, 199 + }); 200 + const routes = await loadBoardsRoutes(); 201 + const res = await routes.request("/boards/42"); 202 + const html = await res.text(); 203 + expect(html).toContain("bob.bsky.social"); 204 + }); 205 + 206 + it("renders relative date in topic row", async () => { 207 + // Use a very recent date so timeAgo returns "just now" 208 + const recentDate = new Date(Date.now() - 5000).toISOString(); 209 + setupSuccessfulFetch({ 210 + topics: [{ id: "1", text: "Recent topic", createdAt: recentDate }], 211 + total: 1, 212 + }); 213 + const routes = await loadBoardsRoutes(); 214 + const res = await routes.request("/boards/42"); 215 + const html = await res.text(); 216 + expect(html).toContain("just now"); 217 + }); 218 + 219 + it("renders reply count placeholder as 0 replies", async () => { 220 + setupSuccessfulFetch({ 221 + topics: [{ id: "1", text: "A topic with replies" }], 222 + total: 1, 223 + }); 224 + const routes = await loadBoardsRoutes(); 225 + const res = await routes.request("/boards/42"); 226 + const html = await res.text(); 227 + // replyCount may not exist in the API response — the UI should render "0 replies" or similar 228 + expect(html).toContain("repl"); // "replies" or "reply" 229 + }); 230 + 231 + it("shows empty state when no topics", async () => { 232 + setupSuccessfulFetch({ topics: [], total: 0 }); 233 + const routes = await loadBoardsRoutes(); 234 + const res = await routes.request("/boards/42"); 235 + const html = await res.text(); 236 + expect(html).toContain("No topics yet"); 237 + }); 238 + 239 + it("shows Load More button when more topics remain", async () => { 240 + setupSuccessfulFetch({ 241 + topics: [ 242 + { id: "1", text: "Topic 1" }, 243 + { id: "2", text: "Topic 2" }, 244 + ], 245 + total: 50, // more than loaded 246 + offset: 0, 247 + }); 248 + const routes = await loadBoardsRoutes(); 249 + const res = await routes.request("/boards/42"); 250 + const html = await res.text(); 251 + expect(html).toContain("Load More"); 252 + expect(html).toContain("hx-get"); 253 + }); 254 + 255 + it("hides Load More button when all topics loaded", async () => { 256 + setupSuccessfulFetch({ 257 + topics: [ 258 + { id: "1", text: "Topic 1" }, 259 + { id: "2", text: "Topic 2" }, 260 + ], 261 + total: 2, // exactly as many as loaded 262 + offset: 0, 263 + }); 264 + const routes = await loadBoardsRoutes(); 265 + const res = await routes.request("/boards/42"); 266 + const html = await res.text(); 267 + expect(html).not.toContain("Load More"); 268 + }); 269 + 270 + it("shows 'Start a new topic' link when authenticated", async () => { 271 + // Session check: needs cookie header → session fetch runs first 272 + mockFetch.mockResolvedValueOnce(authSession); 273 + setupSuccessfulFetch(); 274 + const routes = await loadBoardsRoutes(); 275 + const res = await routes.request("/boards/42", { 276 + headers: { cookie: "atbb_session=token" }, 277 + }); 278 + const html = await res.text(); 279 + expect(html).toContain("Start a new topic"); 280 + expect(html).not.toContain('href="/login"'); 281 + }); 282 + 283 + it("shows 'Log in to start a topic' when unauthenticated", async () => { 284 + setupSuccessfulFetch(); 285 + const routes = await loadBoardsRoutes(); 286 + const res = await routes.request("/boards/42"); 287 + const html = await res.text(); 288 + expect(html).toContain("Log in"); 289 + expect(html).toContain("to start a topic"); 290 + expect(html).not.toContain("Start a new topic"); 291 + }); 292 + 293 + it("returns 404 HTML page when board not found", async () => { 294 + // No session (no cookie) → board fetch returns 404 295 + mockFetch.mockResolvedValueOnce({ 296 + ok: false, 297 + status: 404, 298 + statusText: "Not Found", 299 + }); 300 + mockFetch.mockResolvedValueOnce(makeTopicsResponse()); // topics (parallel, may not be used) 301 + const routes = await loadBoardsRoutes(); 302 + const res = await routes.request("/boards/42"); 303 + expect(res.status).toBe(404); 304 + const html = await res.text(); 305 + expect(res.headers.get("content-type")).toContain("text/html"); 306 + expect(html).toContain("Not Found"); 307 + }); 308 + 309 + it("returns 503 on network error", async () => { 310 + mockFetch.mockRejectedValueOnce( 311 + new Error("AppView network error: fetch failed") 312 + ); 313 + const routes = await loadBoardsRoutes(); 314 + const res = await routes.request("/boards/42"); 315 + expect(res.status).toBe(503); 316 + const html = await res.text(); 317 + expect(html).toContain("error-display"); 318 + expect(html).toContain("unavailable"); 319 + }); 320 + 321 + it("returns 500 on AppView server error", async () => { 322 + mockFetch.mockResolvedValueOnce({ 323 + ok: false, 324 + status: 500, 325 + statusText: "Internal Server Error", 326 + }); 327 + const routes = await loadBoardsRoutes(); 328 + const res = await routes.request("/boards/42"); 329 + expect(res.status).toBe(500); 330 + const html = await res.text(); 331 + expect(html).toContain("error-display"); 332 + expect(html).toContain("Something went wrong"); 333 + }); 334 + 335 + it("re-throws TypeError (programming error)", async () => { 336 + // Return null from board fetch — accessing null.name throws TypeError 337 + mockFetch.mockResolvedValueOnce(mockResponse(null)); 338 + mockFetch.mockResolvedValueOnce(makeTopicsResponse()); 339 + const routes = await loadBoardsRoutes(); 340 + const res = await routes.request("/boards/42"); 341 + // TypeError is re-thrown — Hono's global error handler returns 500 text/plain 342 + expect(res.status).toBe(500); 343 + expect(res.headers.get("content-type")).not.toContain("text/html"); 344 + }); 345 + 346 + it("renders page without category breadcrumb when category fetch fails", async () => { 347 + // Stage 1 (parallel): board + topics succeed 348 + mockFetch.mockResolvedValueOnce(makeBoardResponse()); // GET /api/boards/42 349 + mockFetch.mockResolvedValueOnce(makeTopicsResponse()); // GET /api/boards/42/topics 350 + // Stage 2: category fetch fails (server error) 351 + mockFetch.mockResolvedValueOnce({ ok: false, status: 500, statusText: "Internal Server Error" }); 352 + 353 + const routes = await loadBoardsRoutes(); 354 + const res = await routes.request("/boards/42"); 355 + expect(res.status).toBe(200); // Page still renders! 356 + const html = await res.text(); 357 + expect(html).toContain("General Discussion"); // Board name present 358 + // Category name absent from breadcrumb but page is functional 359 + expect(html).not.toContain("Main Category"); 360 + }); 361 + 362 + // ────────────────────────────────────────────────────────────────────────── 363 + // HTMX partial tests 364 + // ────────────────────────────────────────────────────────────────────────── 365 + 366 + it("HTMX partial returns topic rows HTML fragment", async () => { 367 + mockFetch.mockResolvedValueOnce( 368 + makeTopicsResponse( 369 + [{ id: "5", text: "HTMX loaded topic", handle: "carol.bsky.social" }], 370 + 26, 371 + 25 372 + ) 373 + ); 374 + const routes = await loadBoardsRoutes(); 375 + const res = await routes.request("/boards/42?offset=25", { 376 + headers: { "HX-Request": "true" }, 377 + }); 378 + expect(res.status).toBe(200); 379 + const html = await res.text(); 380 + expect(html).toContain("HTMX loaded topic"); 381 + expect(html).toContain("carol.bsky.social"); 382 + }); 383 + 384 + it("HTMX partial returns empty fragment on error", async () => { 385 + mockFetch.mockRejectedValueOnce(new Error("AppView network error: fetch failed")); 386 + const routes = await loadBoardsRoutes(); 387 + const res = await routes.request("/boards/42?offset=25", { 388 + headers: { "HX-Request": "true" }, 389 + }); 390 + expect(res.status).toBe(200); 391 + const html = await res.text(); 392 + // Empty fragment — no error display, no crash 393 + expect(html.trim()).toBe(""); 394 + }); 395 + });
-31
apps/web/src/routes/__tests__/stubs.test.tsx
··· 85 85 expect(html).not.toContain('href="/login"'); 86 86 }); 87 87 88 - it("GET /boards/:id returns 200 with board title", async () => { 89 - const { createBoardsRoutes } = await import("../boards.js"); 90 - const routes = createBoardsRoutes("http://localhost:3000"); 91 - const res = await routes.request("/boards/123"); 92 - expect(res.status).toBe(200); 93 - const html = await res.text(); 94 - expect(html).toContain("Board — atBB Forum"); 95 - }); 96 - 97 - it("GET /boards/:id shows 'Log in to start a topic' when unauthenticated", async () => { 98 - const { createBoardsRoutes } = await import("../boards.js"); 99 - const routes = createBoardsRoutes("http://localhost:3000"); 100 - const res = await routes.request("/boards/123"); 101 - const html = await res.text(); 102 - expect(html).toContain("Log in"); 103 - expect(html).toContain("to start a topic"); 104 - expect(html).not.toContain("Start a new topic"); 105 - }); 106 - 107 - it("GET /boards/:id shows 'Start a new topic' link when authenticated", async () => { 108 - mockFetch.mockResolvedValueOnce(authenticatedSession); 109 - const { createBoardsRoutes } = await import("../boards.js"); 110 - const routes = createBoardsRoutes("http://localhost:3000"); 111 - const res = await routes.request("/boards/123", { 112 - headers: { cookie: "atbb_session=token" }, 113 - }); 114 - const html = await res.text(); 115 - expect(html).toContain("Start a new topic"); 116 - expect(html).not.toContain("Log in"); 117 - }); 118 - 119 88 it("GET /topics/:id returns 200 with topic title", async () => { 120 89 const { createTopicsRoutes } = await import("../topics.js"); 121 90 const routes = createTopicsRoutes("http://localhost:3000");
+269 -9
apps/web/src/routes/boards.tsx
··· 1 1 import { Hono } from "hono"; 2 2 import { BaseLayout } from "../layouts/base.js"; 3 - import { PageHeader, EmptyState } from "../components/index.js"; 3 + import { PageHeader, EmptyState, ErrorDisplay } from "../components/index.js"; 4 + import { fetchApi } from "../lib/api.js"; 4 5 import { getSession } from "../lib/session.js"; 6 + import { isProgrammingError, isNetworkError, isNotFoundError } from "../lib/errors.js"; 7 + import { timeAgo } from "../lib/time.js"; 8 + 9 + // API response type shapes 10 + 11 + interface BoardResponse { 12 + id: string; 13 + did: string; 14 + name: string; 15 + description: string | null; 16 + slug: string | null; 17 + sortOrder: number | null; 18 + categoryId: string; 19 + categoryUri: string | null; 20 + createdAt: string | null; 21 + indexedAt: string | null; 22 + } 23 + 24 + interface CategoryResponse { 25 + id: string; 26 + did: string; 27 + name: string; 28 + description: string | null; 29 + slug: string | null; 30 + sortOrder: number | null; 31 + forumId: string | null; 32 + createdAt: string | null; 33 + indexedAt: string | null; 34 + } 35 + 36 + interface AuthorResponse { 37 + did: string; 38 + handle: string | null; 39 + } 40 + 41 + interface TopicResponse { 42 + id: string; 43 + did: string; 44 + rkey: string; 45 + text: string; 46 + forumUri: string | null; 47 + boardUri: string | null; 48 + boardId: string | null; 49 + parentPostId: string | null; 50 + createdAt: string | null; 51 + author: AuthorResponse | null; 52 + } 53 + 54 + interface TopicsListResponse { 55 + topics: TopicResponse[]; 56 + total: number; 57 + offset: number; 58 + limit: number; 59 + } 60 + 61 + // ─── Inline components ────────────────────────────────────────────────────── 62 + 63 + function TopicRow({ topic }: { topic: TopicResponse }) { 64 + const title = topic.text.slice(0, 80); 65 + const handle = topic.author?.handle ?? topic.author?.did ?? topic.did; 66 + const date = topic.createdAt ? timeAgo(new Date(topic.createdAt)) : "unknown"; 67 + return ( 68 + <div class="topic-row"> 69 + <a href={`/topics/${topic.id}`} class="topic-row__title"> 70 + {title} 71 + </a> 72 + <div class="topic-row__meta"> 73 + <span>by {handle}</span> 74 + <span>{date}</span> 75 + <span>0 replies</span> 76 + </div> 77 + </div> 78 + ); 79 + } 80 + 81 + function LoadMoreButton({ 82 + boardId, 83 + nextOffset, 84 + }: { 85 + boardId: string; 86 + nextOffset: number; 87 + }) { 88 + return ( 89 + <button 90 + hx-get={`/boards/${boardId}?offset=${nextOffset}`} 91 + hx-swap="outerHTML" 92 + hx-target="this" 93 + hx-indicator="#loading-spinner" 94 + > 95 + Load More 96 + </button> 97 + ); 98 + } 99 + 100 + function TopicFragment({ 101 + boardId, 102 + topics, 103 + total, 104 + offset, 105 + }: { 106 + boardId: string; 107 + topics: TopicResponse[]; 108 + total: number; 109 + offset: number; 110 + }) { 111 + const nextOffset = offset + topics.length; 112 + const hasMore = nextOffset < total; 113 + return ( 114 + <> 115 + {topics.map((topic) => ( 116 + <TopicRow key={topic.id} topic={topic} /> 117 + ))} 118 + {hasMore && ( 119 + <LoadMoreButton boardId={boardId} nextOffset={nextOffset} /> 120 + )} 121 + </> 122 + ); 123 + } 124 + 125 + // ─── Route factory ─────────────────────────────────────────────────────────── 5 126 6 127 export function createBoardsRoutes(appviewUrl: string) { 7 128 return new Hono().get("/boards/:id", async (c) => { 129 + const idParam = c.req.param("id"); 130 + 131 + // Validate that the ID is an integer (parseable as BigInt) 132 + if (!/^\d+$/.test(idParam)) { 133 + // HTMX partial mode: return empty fragment silently 134 + if (c.req.header("HX-Request")) { 135 + return c.html("", 200); 136 + } 137 + return c.html( 138 + <BaseLayout title="Bad Request — atBB Forum"> 139 + <ErrorDisplay message="Invalid board ID." /> 140 + </BaseLayout>, 141 + 400 142 + ); 143 + } 144 + 145 + const boardId = idParam; 146 + 147 + // ── HTMX partial mode ────────────────────────────────────────────────── 148 + if (c.req.header("HX-Request")) { 149 + const offsetRaw = parseInt(c.req.query("offset") ?? "0", 10); 150 + const offset = isNaN(offsetRaw) || offsetRaw < 0 ? 0 : offsetRaw; 151 + 152 + try { 153 + const topicsData = await fetchApi<TopicsListResponse>( 154 + `/boards/${boardId}/topics?offset=${offset}&limit=25` 155 + ); 156 + const { topics, total } = topicsData; 157 + 158 + return c.html( 159 + <TopicFragment 160 + boardId={boardId} 161 + topics={topics} 162 + total={total} 163 + offset={offset} 164 + />, 165 + 200 166 + ); 167 + } catch (error) { 168 + if (isProgrammingError(error)) throw error; 169 + console.error("Failed to load topics for HTMX partial request", { 170 + operation: "GET /boards/:id (HTMX partial)", 171 + boardId, 172 + offset, 173 + error: error instanceof Error ? error.message : String(error), 174 + }); 175 + // On any error in HTMX partial mode, return empty fragment to avoid breaking the page 176 + return c.html("", 200); 177 + } 178 + } 179 + 180 + // ── Full page mode ──────────────────────────────────────────────────── 8 181 const auth = await getSession(appviewUrl, c.req.header("cookie")); 182 + 183 + // Stage 1: fetch board metadata and topics in parallel 184 + let board: BoardResponse; 185 + let topicsData: TopicsListResponse; 186 + try { 187 + const [boardResult, topicsResult] = await Promise.all([ 188 + fetchApi<BoardResponse>(`/boards/${boardId}`), 189 + fetchApi<TopicsListResponse>(`/boards/${boardId}/topics?offset=0&limit=25`), 190 + ]); 191 + board = boardResult; 192 + topicsData = topicsResult; 193 + } catch (error) { 194 + if (isProgrammingError(error)) throw error; 195 + 196 + if (isNotFoundError(error)) { 197 + return c.html( 198 + <BaseLayout title="Not Found — atBB Forum" auth={auth}> 199 + <ErrorDisplay message="This board doesn't exist." /> 200 + </BaseLayout>, 201 + 404 202 + ); 203 + } 204 + 205 + console.error("Failed to load board page data (stage 1: board + topics)", { 206 + operation: "GET /boards/:id", 207 + boardId, 208 + error: error instanceof Error ? error.message : String(error), 209 + }); 210 + const status = isNetworkError(error) ? 503 : 500; 211 + const message = 212 + status === 503 213 + ? "The forum is temporarily unavailable. Please try again later." 214 + : "Something went wrong loading this board. Please try again later."; 215 + return c.html( 216 + <BaseLayout title="Error — atBB Forum" auth={auth}> 217 + <ErrorDisplay message={message} /> 218 + </BaseLayout>, 219 + status 220 + ); 221 + } 222 + 223 + // Stage 2: fetch category for breadcrumb (non-fatal — page renders without category name) 224 + let categoryName: string | null = null; 225 + try { 226 + const category = await fetchApi<CategoryResponse>(`/categories/${board.categoryId}`); 227 + categoryName = category.name; 228 + } catch (error) { 229 + if (isProgrammingError(error)) throw error; 230 + console.error("Failed to load board page data (stage 2: category)", { 231 + operation: "GET /boards/:id", 232 + boardId: board.id, 233 + categoryId: board.categoryId, 234 + error: error instanceof Error ? error.message : String(error), 235 + }); 236 + // categoryName remains null — page renders without category in breadcrumb 237 + } 238 + 239 + const { topics, total, offset } = topicsData; 240 + 9 241 return c.html( 10 - <BaseLayout title="Board — atBB Forum" auth={auth}> 11 - <PageHeader title="Board" description="Topics will appear here." /> 12 - {auth.authenticated ? ( 13 - <p> 14 - <a href="/new-topic">Start a new topic</a> 15 - </p> 242 + <BaseLayout title={`${board.name} — atBB Forum`} auth={auth}> 243 + <nav class="breadcrumb"> 244 + <a href="/">Home</a> 245 + {categoryName && ( 246 + <> 247 + {" / "} 248 + <a href="/">{categoryName}</a> 249 + </> 250 + )} 251 + {" / "} 252 + <span>{board.name}</span> 253 + </nav> 254 + 255 + <PageHeader 256 + title={board.name} 257 + description={board.description ?? undefined} 258 + /> 259 + 260 + {auth?.authenticated ? ( 261 + <a href={`/new-topic?boardId=${boardId}`} class="btn"> 262 + Start a new topic 263 + </a> 16 264 ) : ( 17 265 <p> 18 - <a href="/login">Log in</a> to start a topic. 266 + <a href="/login">Log in</a> to start a topic 19 267 </p> 20 268 )} 21 - <EmptyState message="No topics yet." /> 269 + 270 + <div id="topic-list"> 271 + {topics.length === 0 ? ( 272 + <EmptyState message="No topics yet." /> 273 + ) : ( 274 + <TopicFragment 275 + boardId={boardId} 276 + topics={topics} 277 + total={total} 278 + offset={offset} 279 + /> 280 + )} 281 + </div> 22 282 </BaseLayout> 23 283 ); 24 284 });
+13 -1
bruno/AppView API/Boards/Get Board Topics.bru
··· 8 8 url: {{appview_url}}/api/boards/1/topics 9 9 } 10 10 11 + params:query { 12 + ~offset: 0 13 + ~limit: 25 14 + } 15 + 11 16 assert { 12 17 res.status: eq 200 13 18 res.body.topics: isDefined ··· 19 24 Path parameters: 20 25 - id: Board ID (numeric) 21 26 27 + Query parameters: 28 + - offset: Number of topics to skip (optional, default 0) 29 + - limit: Maximum number of topics to return (optional, default 25, max 100) 30 + 22 31 Returns: 23 32 { 24 33 "topics": [ ··· 34 43 "createdAt": "2026-02-13T00:00:00.000Z", 35 44 "author": { "did": "...", "handle": "..." } | null 36 45 } 37 - ] 46 + ], 47 + "total": 42, 48 + "offset": 0, 49 + "limit": 25 38 50 } 39 51 40 52 Error codes:
+41
bruno/AppView API/Boards/Get Board.bru
··· 1 + meta { 2 + name: Get Board 3 + type: http 4 + seq: 2 5 + } 6 + 7 + get { 8 + url: {{appview_url}}/api/boards/1 9 + } 10 + 11 + assert { 12 + res.status: eq 200 13 + res.body.id: isDefined 14 + res.body.name: isDefined 15 + } 16 + 17 + docs { 18 + Returns a single board by ID. 19 + 20 + Path params: 21 + - id: Board ID (integer) 22 + 23 + Returns: 24 + { 25 + "id": "1", 26 + "did": "did:plc:...", 27 + "name": "General Discussion", 28 + "description": "A place for general topics", 29 + "categoryId": "1", 30 + "slug": null, 31 + "sortOrder": null, 32 + "forumId": "1", 33 + "createdAt": "...", 34 + "indexedAt": "..." 35 + } 36 + 37 + Error codes: 38 + - 400: Invalid board ID (non-integer) 39 + - 404: Board not found 40 + - 500: Server error 41 + }
+40
bruno/AppView API/Categories/Get Category.bru
··· 1 + meta { 2 + name: Get Category 3 + type: http 4 + seq: 2 5 + } 6 + 7 + get { 8 + url: {{appview_url}}/api/categories/1 9 + } 10 + 11 + assert { 12 + res.status: eq 200 13 + res.body.id: isDefined 14 + res.body.name: isDefined 15 + } 16 + 17 + docs { 18 + Returns a single category by ID. 19 + 20 + Path params: 21 + - id: Category ID (integer) 22 + 23 + Returns: 24 + { 25 + "id": "1", 26 + "did": "did:plc:...", 27 + "name": "General", 28 + "description": null, 29 + "slug": null, 30 + "sortOrder": null, 31 + "forumId": "1", 32 + "createdAt": "...", 33 + "indexedAt": "..." 34 + } 35 + 36 + Error codes: 37 + - 400: Invalid category ID (non-integer) 38 + - 404: Category not found 39 + - 500: Server error 40 + }
+2
docs/atproto-forum-plan.md
··· 219 219 #### Phase 4: Web UI (Week 7–9) 220 220 - [x] Forum homepage: category list, recent topics 221 221 - ATB-27 | `apps/web/src/routes/home.tsx` — server-renders forum name/description, categories as section headers, boards as cards with links; two-stage parallel fetch (forum+categories, then per-category boards); error display on network (503) or API (500) failures; 12 integration tests in `home.test.tsx` 222 + - [x] Board view: topic listing with pagination 223 + - ATB-28 | `apps/web/src/routes/boards.tsx` — breadcrumb navigation, topic list (truncated 80 chars), HTMX "Load More" pagination; auth-aware "New Topic" button; `timeAgo` relative date utility; `isNotFoundError` error helper; AppView: `GET /api/boards/:id`, `GET /api/categories/:id`, offset/limit pagination on `GET /api/boards/:id/topics`; 20 integration tests in `boards.test.tsx`; 8 AppView tests 222 224 - [ ] Category view: paginated topic list, sorted by last reply 223 225 - [ ] Topic view: OP + flat replies, pagination 224 226 - [ ] Compose: new topic form, reply form
+230
docs/plans/2026-02-18-board-view-design.md
··· 1 + # Board View — Topic Listing with Pagination (ATB-28) 2 + 3 + **Date:** 2026-02-18 4 + **Linear:** [ATB-28](https://linear.app/atbb/issue/ATB-28) 5 + **Phase:** 4 — Web UI 6 + 7 + --- 8 + 9 + ## Summary 10 + 11 + Implement the board view page (`GET /boards/:id`) in the web app, showing the topic 12 + listing for a board with HTMX-powered "Load More" pagination. Three layers of changes: 13 + AppView new endpoints, AppView pagination, and web app route implementation. 14 + 15 + --- 16 + 17 + ## AppView Changes 18 + 19 + ### New: `GET /api/boards/:id` 20 + 21 + Returns a single board by ID. 22 + 23 + - **400** — invalid ID format (non-integer) 24 + - **404** — board not found 25 + - **200** — `serializeBoard` shape (same as list endpoint) 26 + 27 + ### New: `GET /api/categories/:id` 28 + 29 + Returns a single category by ID. 30 + 31 + - **400** — invalid ID format 32 + - **404** — category not found 33 + - **200** — `serializeCategory` shape (same as list endpoint) 34 + 35 + ### Modified: `GET /api/boards/:id/topics` 36 + 37 + Add optional query params: `?offset=N&limit=M`. 38 + 39 + - Default `limit`: 25 40 + - Max `limit`: 100 (clamped) 41 + - Default `offset`: 0 42 + - Response adds `total`, `offset`, `limit` fields: 43 + 44 + ```json 45 + { 46 + "topics": [...], 47 + "total": 142, 48 + "offset": 0, 49 + "limit": 25 50 + } 51 + ``` 52 + 53 + The `total` count uses a `COUNT(*)` subquery in the same transaction. 54 + 55 + --- 56 + 57 + ## Web App: `boards.tsx` 58 + 59 + ### Fetch Stages 60 + 61 + **Stage 1 (parallel):** 62 + - `GET /api/boards/:id` — board name, description, categoryId 63 + - `GET /api/boards/:id/topics?offset=0&limit=25` — first page of topics + total 64 + 65 + **Stage 2 (sequential, after stage 1):** 66 + - `GET /api/categories/${board.categoryId}` — category name for breadcrumbs 67 + 68 + ### Error Handling 69 + 70 + | Condition | Response | 71 + |-----------|----------| 72 + | Non-integer `:id` | 400 (guard before API call) | 73 + | Board not found (AppView 404) | 404 HTML page | 74 + | Network error | 503 + `ErrorDisplay` | 75 + | AppView server error | 500 + `ErrorDisplay` | 76 + | Programming error (`TypeError`) | re-throw (let global handler catch) | 77 + 78 + ### Page Structure 79 + 80 + ``` 81 + Breadcrumb: [Home] / [Category Name] / Board Name 82 + 83 + # Board Name 84 + Board description (if present) 85 + 86 + [+ New Topic] (link to /new-topic?boardId=:id) 87 + 88 + Topic list: 89 + ┌──────────────────────────────────────────────┐ 90 + │ Topic Title (first 80 chars of text) │ 91 + │ by alice.bsky.social · 2 hours ago 3 replies│ 92 + ├──────────────────────────────────────────────┤ 93 + │ Another Topic │ 94 + │ by bob.bsky.social · 3 days ago 0 replies│ 95 + └──────────────────────────────────────────────┘ 96 + 97 + [ LOAD MORE ] ← shown only when offset + count < total 98 + (hidden when all topics are loaded) 99 + 100 + Empty state: "No topics yet." (when topics is empty) 101 + ``` 102 + 103 + ### New Topic Button 104 + 105 + - Visible to all users 106 + - Authenticated: links to `/new-topic?boardId=:id` 107 + - Unauthenticated: links to `/login` with a "Log in to start a topic" message 108 + 109 + ### HTMX "Load More" 110 + 111 + The route handles two modes based on the `HX-Request` header: 112 + 113 + 1. **Full page** (no `HX-Request`): Returns complete HTML with all chrome 114 + 2. **HTMX partial** (`HX-Request: true` with `?offset=N`): Returns topic rows + 115 + a new "Load More" button (or nothing if end of list) 116 + 117 + Button attributes: 118 + ```html 119 + <button 120 + hx-get="/boards/42?offset=25" 121 + hx-swap="outerHTML" 122 + hx-target="this" 123 + hx-indicator="#loading-spinner" 124 + > 125 + Load More 126 + </button> 127 + ``` 128 + 129 + The button is `outerHTML`-swapped with either: 130 + - Next batch of rows + new button (more topics remain) 131 + - Just the rows (last page reached) 132 + 133 + ### `timeAgo` Utility 134 + 135 + New file: `apps/web/src/lib/time.ts` 136 + 137 + ```typescript 138 + export function timeAgo(date: Date): string 139 + ``` 140 + 141 + Formats relative dates without a library: 142 + - < 1 min: "just now" 143 + - < 1 hour: "N minutes ago" 144 + - < 24 hours: "N hours ago" 145 + - < 30 days: "N days ago" 146 + - Otherwise: ISO date string (YYYY-MM-DD) 147 + 148 + --- 149 + 150 + ## Testing Plan 151 + 152 + ### AppView Tests 153 + 154 + **`apps/appview/src/routes/__tests__/boards.test.ts`** (add to existing): 155 + - `GET /api/boards/:id` — returns 200 with board data 156 + - `GET /api/boards/:id` — returns 404 for unknown ID 157 + - `GET /api/boards/:id` — returns 400 for non-integer ID 158 + - `GET /api/boards/:id/topics?offset=25&limit=10` — applies offset/limit 159 + - `GET /api/boards/:id/topics` — response includes `total`, `offset`, `limit` fields 160 + - `GET /api/boards/:id/topics?offset=1000` — returns empty topics array (beyond end) 161 + 162 + **`apps/appview/src/routes/__tests__/categories.test.ts`** (add to existing): 163 + - `GET /api/categories/:id` — returns 200 with category data 164 + - `GET /api/categories/:id` — returns 404 for unknown ID 165 + - `GET /api/categories/:id` — returns 400 for non-integer ID 166 + 167 + ### Web App Tests 168 + 169 + **`apps/web/src/routes/__tests__/boards.test.tsx`** (new comprehensive file): 170 + 171 + Replace the stub tests in `stubs.test.tsx` with full tests: 172 + 173 + - Renders board name as page title 174 + - Renders board description 175 + - Renders breadcrumb: Home → Category Name → Board Name 176 + - Breadcrumb links are correct (`/` and `/`) 177 + - Topic list renders with truncated title (80 chars) 178 + - Topic list renders author handle 179 + - Topic list renders relative date 180 + - Empty state when topics array is empty 181 + - "New Topic" button shown and links to `/new-topic?boardId=:id` when authenticated 182 + - "Log in" link shown when unauthenticated 183 + - "Load More" button present when offset + count < total 184 + - "Load More" button absent when all topics loaded 185 + - Returns 404 HTML when AppView returns 404 for board 186 + - Returns 503 on network error 187 + - Returns 500 on AppView server error 188 + - Re-throws `TypeError` (programming error path) 189 + - HTMX partial request: returns topic rows only (no full HTML chrome) 190 + 191 + ### `timeAgo` Tests 192 + 193 + **`apps/web/src/lib/__tests__/time.test.ts`** (new): 194 + - "just now" for < 60 seconds 195 + - "N minutes ago" for < 60 minutes 196 + - "N hours ago" for < 24 hours 197 + - "N days ago" for < 30 days 198 + - ISO date string for older dates 199 + 200 + --- 201 + 202 + ## Files Changed 203 + 204 + ### New Files 205 + - `apps/web/src/lib/time.ts` — `timeAgo` utility 206 + - `apps/web/src/lib/__tests__/time.test.ts` — tests for timeAgo 207 + - `apps/web/src/routes/__tests__/boards.test.tsx` — comprehensive board view tests 208 + 209 + ### Modified Files 210 + - `apps/appview/src/routes/boards.ts` — add `GET /:id` + pagination to `GET /:id/topics` 211 + - `apps/appview/src/routes/categories.ts` — add `GET /:id` 212 + - `apps/appview/src/routes/__tests__/boards.test.ts` — new endpoint tests 213 + - `apps/appview/src/routes/__tests__/categories.test.ts` — new endpoint tests 214 + - `apps/web/src/routes/boards.tsx` — full implementation (replaces stub) 215 + - `apps/web/src/routes/__tests__/stubs.test.tsx` — remove boards stub tests 216 + - `bruno/AppView API/Boards/Get Board.bru` — new Bruno collection entry 217 + - `bruno/AppView API/Categories/Get Category.bru` — new Bruno collection entry 218 + - `bruno/AppView API/Boards/Get Board Topics.bru` — update with pagination params 219 + 220 + --- 221 + 222 + ## Decisions 223 + 224 + | Decision | Choice | Rationale | 225 + |----------|--------|-----------| 226 + | Missing board endpoints | Add them | Clean REST API; avoids fetching all-then-filter | 227 + | Pagination style | Server-side offset/limit | Scale-correct; AppView owns the data | 228 + | Pagination UX | HTMX Load More | Matches existing HTMX investment; smooth UX | 229 + | Relative dates | Custom `timeAgo` util | No library dependency for a simple utility | 230 + | Breadcrumb data source | `GET /api/categories/:id` after stage 1 | Sequential fetch is fine; category ID is in board response |
+1550
docs/plans/2026-02-18-board-view-impl.md
··· 1 + # Board View — Topic Listing with Pagination Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Implement `GET /boards/:id` in the web app with topic listing, breadcrumbs, auth-aware "New Topic" button, and HTMX-powered "Load More" pagination. 6 + 7 + **Architecture:** Add two new AppView REST endpoints (`GET /api/boards/:id`, `GET /api/categories/:id`) and server-side pagination (`?offset&limit`) to the topics endpoint. The web route fetches board + topics in parallel, then fetches the parent category for breadcrumbs. HTMX partial mode is triggered by the `HX-Request` header for the "Load More" button. 8 + 9 + **Tech Stack:** Hono, Drizzle ORM, HTMX, Vitest. Run commands with PATH set to include `.devenv/profile/bin`. 10 + 11 + --- 12 + 13 + ## Environment Setup 14 + 15 + All bash commands in this plan require: 16 + ```bash 17 + export PATH="$PWD/.devenv/profile/bin:$PATH" 18 + ``` 19 + 20 + Run tests: 21 + ```bash 22 + pnpm --filter @atbb/appview test # AppView tests only 23 + pnpm --filter @atbb/web test # Web tests only 24 + pnpm test # All tests 25 + ``` 26 + 27 + --- 28 + 29 + ## Task 1: `timeAgo` Utility 30 + 31 + **Files:** 32 + - Create: `apps/web/src/lib/time.ts` 33 + - Create: `apps/web/src/lib/__tests__/time.test.ts` 34 + 35 + **Step 1: Write the failing test** 36 + 37 + Create `apps/web/src/lib/__tests__/time.test.ts`: 38 + 39 + ```typescript 40 + import { describe, it, expect, vi, afterEach } from "vitest"; 41 + import { timeAgo } from "../time.js"; 42 + 43 + describe("timeAgo", () => { 44 + afterEach(() => { 45 + vi.useRealTimers(); 46 + }); 47 + 48 + function setNow(iso: string) { 49 + vi.useFakeTimers(); 50 + vi.setSystemTime(new Date(iso)); 51 + } 52 + 53 + it("returns 'just now' for under 60 seconds", () => { 54 + setNow("2026-01-01T12:00:30Z"); 55 + expect(timeAgo(new Date("2026-01-01T12:00:00Z"))).toBe("just now"); 56 + }); 57 + 58 + it("returns '1 minute ago' (singular)", () => { 59 + setNow("2026-01-01T12:01:00Z"); 60 + expect(timeAgo(new Date("2026-01-01T12:00:00Z"))).toBe("1 minute ago"); 61 + }); 62 + 63 + it("returns 'N minutes ago' for under 60 minutes", () => { 64 + setNow("2026-01-01T12:05:00Z"); 65 + expect(timeAgo(new Date("2026-01-01T12:00:00Z"))).toBe("5 minutes ago"); 66 + }); 67 + 68 + it("returns '1 hour ago' (singular)", () => { 69 + setNow("2026-01-01T13:00:00Z"); 70 + expect(timeAgo(new Date("2026-01-01T12:00:00Z"))).toBe("1 hour ago"); 71 + }); 72 + 73 + it("returns 'N hours ago' for under 24 hours", () => { 74 + setNow("2026-01-01T15:00:00Z"); 75 + expect(timeAgo(new Date("2026-01-01T12:00:00Z"))).toBe("3 hours ago"); 76 + }); 77 + 78 + it("returns '1 day ago' (singular)", () => { 79 + setNow("2026-01-02T12:00:00Z"); 80 + expect(timeAgo(new Date("2026-01-01T12:00:00Z"))).toBe("1 day ago"); 81 + }); 82 + 83 + it("returns 'N days ago' for under 30 days", () => { 84 + setNow("2026-01-08T12:00:00Z"); 85 + expect(timeAgo(new Date("2026-01-01T12:00:00Z"))).toBe("7 days ago"); 86 + }); 87 + 88 + it("returns ISO date (YYYY-MM-DD) for 30+ days old", () => { 89 + setNow("2026-02-01T12:00:00Z"); 90 + expect(timeAgo(new Date("2026-01-01T12:00:00Z"))).toBe("2026-01-01"); 91 + }); 92 + }); 93 + ``` 94 + 95 + **Step 2: Run test to verify it fails** 96 + 97 + ```bash 98 + export PATH="$PWD/.devenv/profile/bin:$PATH" 99 + pnpm --filter @atbb/web exec vitest run src/lib/__tests__/time.test.ts 100 + ``` 101 + Expected: FAIL with "Cannot find module '../time.js'" 102 + 103 + **Step 3: Implement `timeAgo`** 104 + 105 + Create `apps/web/src/lib/time.ts`: 106 + 107 + ```typescript 108 + /** 109 + * Returns a human-readable relative time string for a given date. 110 + * Examples: "just now", "5 minutes ago", "2 hours ago", "3 days ago", "2026-01-01" 111 + */ 112 + export function timeAgo(date: Date): string { 113 + const diffMs = Date.now() - date.getTime(); 114 + const diffSecs = Math.floor(diffMs / 1000); 115 + 116 + if (diffSecs < 60) return "just now"; 117 + 118 + const diffMins = Math.floor(diffSecs / 60); 119 + if (diffMins < 60) return `${diffMins} minute${diffMins === 1 ? "" : "s"} ago`; 120 + 121 + const diffHours = Math.floor(diffMins / 60); 122 + if (diffHours < 24) return `${diffHours} hour${diffHours === 1 ? "" : "s"} ago`; 123 + 124 + const diffDays = Math.floor(diffHours / 24); 125 + if (diffDays < 30) return `${diffDays} day${diffDays === 1 ? "" : "s"} ago`; 126 + 127 + return date.toISOString().split("T")[0]; 128 + } 129 + ``` 130 + 131 + **Step 4: Run tests to verify they pass** 132 + 133 + ```bash 134 + export PATH="$PWD/.devenv/profile/bin:$PATH" 135 + pnpm --filter @atbb/web exec vitest run src/lib/__tests__/time.test.ts 136 + ``` 137 + Expected: All 8 tests PASS 138 + 139 + **Step 5: Commit** 140 + 141 + ```bash 142 + git add apps/web/src/lib/time.ts apps/web/src/lib/__tests__/time.test.ts 143 + git commit -m "feat(web): add timeAgo utility for relative date formatting" 144 + ``` 145 + 146 + --- 147 + 148 + ## Task 2: `isNotFoundError` Helper 149 + 150 + **Files:** 151 + - Modify: `apps/web/src/lib/errors.ts` 152 + - Modify: `apps/web/src/lib/__tests__/` (check if an errors test exists; if not, tests live inline in Task 6) 153 + 154 + **Step 1: Check for existing errors test** 155 + 156 + ```bash 157 + ls apps/web/src/lib/__tests__/ 158 + ``` 159 + 160 + If no `errors.test.ts` exists, skip to step 3 (errors helper is tested implicitly in Task 6). 161 + 162 + **Step 2: Add `isNotFoundError` to `apps/web/src/lib/errors.ts`** 163 + 164 + Open `apps/web/src/lib/errors.ts` and append after the existing `isNetworkError` function: 165 + 166 + ```typescript 167 + /** 168 + * Returns true if the error is an AppView 404 response. 169 + * Callers should return a 404 HTML page to the user. 170 + * 171 + * fetchApi() throws with this prefix for non-ok HTTP responses: 172 + * "AppView API error: 404 Not Found" 173 + */ 174 + export function isNotFoundError(error: unknown): boolean { 175 + return ( 176 + error instanceof Error && 177 + error.message.startsWith("AppView API error: 404") 178 + ); 179 + } 180 + ``` 181 + 182 + **Step 3: Commit** 183 + 184 + ```bash 185 + git add apps/web/src/lib/errors.ts 186 + git commit -m "feat(web): add isNotFoundError helper for AppView 404 responses" 187 + ``` 188 + 189 + --- 190 + 191 + ## Task 3: AppView `GET /api/boards/:id` 192 + 193 + **Files:** 194 + - Modify: `apps/appview/src/routes/boards.ts` 195 + - Modify: `apps/appview/src/routes/__tests__/boards.test.ts` 196 + 197 + **Step 1: Write the failing tests** 198 + 199 + Open `apps/appview/src/routes/__tests__/boards.test.ts` and add a new `describe` block AFTER the existing ones: 200 + 201 + ```typescript 202 + describe("GET /api/boards/:id", () => { 203 + let ctx: TestContext; 204 + let app: Hono; 205 + let boardId: bigint; 206 + let cleanedUp = false; 207 + 208 + beforeEach(async () => { 209 + ctx = await createTestContext(); 210 + app = new Hono().route("/api/boards", createBoardsRoutes(ctx)); 211 + cleanedUp = false; 212 + 213 + await ctx.db.delete(posts); 214 + await ctx.db.delete(boards).where(eq(boards.did, ctx.config.forumDid)); 215 + await ctx.db.delete(categories).where(eq(categories.did, ctx.config.forumDid)); 216 + 217 + const [testForum] = await ctx.db 218 + .select() 219 + .from(forums) 220 + .where(eq(forums.did, ctx.config.forumDid)) 221 + .limit(1); 222 + 223 + const [cat] = await ctx.db.insert(categories).values({ 224 + did: ctx.config.forumDid, 225 + rkey: "cat1", 226 + cid: "bafycat1", 227 + name: "General", 228 + forumId: testForum.id, 229 + sortOrder: 1, 230 + createdAt: new Date(), 231 + indexedAt: new Date(), 232 + }).returning(); 233 + 234 + const [board] = await ctx.db.insert(boards).values({ 235 + did: ctx.config.forumDid, 236 + rkey: "board1", 237 + cid: "bafyboard1", 238 + name: "Announcements", 239 + description: "Official announcements", 240 + sortOrder: 1, 241 + categoryId: cat.id, 242 + categoryUri: `at://${ctx.config.forumDid}/space.atbb.forum.category/cat1`, 243 + createdAt: new Date(), 244 + indexedAt: new Date(), 245 + }).returning(); 246 + 247 + boardId = board.id; 248 + }); 249 + 250 + afterEach(async () => { 251 + if (!cleanedUp) await ctx.cleanup(); 252 + }); 253 + 254 + it("returns board by ID", async () => { 255 + const res = await app.request(`/api/boards/${boardId}`); 256 + expect(res.status).toBe(200); 257 + const data = await res.json(); 258 + expect(data.name).toBe("Announcements"); 259 + expect(data.description).toBe("Official announcements"); 260 + expect(typeof data.id).toBe("string"); 261 + expect(data.id).toMatch(/^\d+$/); 262 + }); 263 + 264 + it("returns 404 for non-existent board", async () => { 265 + const res = await app.request("/api/boards/999999"); 266 + expect(res.status).toBe(404); 267 + const data = await res.json(); 268 + expect(data.error).toBe("Board not found"); 269 + }); 270 + 271 + it("returns 400 for non-integer ID", async () => { 272 + const res = await app.request("/api/boards/not-a-number"); 273 + expect(res.status).toBe(400); 274 + const data = await res.json(); 275 + expect(data.error).toBe("Invalid board ID format"); 276 + }); 277 + 278 + it("returns 500 on database error", async () => { 279 + await ctx.cleanup(); 280 + cleanedUp = true; 281 + const res = await app.request(`/api/boards/${boardId}`); 282 + expect(res.status).toBe(500); 283 + const data = await res.json(); 284 + expect(data.error).toBe("Failed to retrieve board. Please try again later."); 285 + }); 286 + }); 287 + ``` 288 + 289 + **Step 2: Run tests to verify they fail** 290 + 291 + ```bash 292 + export PATH="$PWD/.devenv/profile/bin:$PATH" 293 + pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/boards.test.ts 294 + ``` 295 + Expected: The 4 new tests FAIL with "GET /api/boards/:id not found" or similar 296 + 297 + **Step 3: Implement `GET /api/boards/:id`** 298 + 299 + Open `apps/appview/src/routes/boards.ts`. Add `.get("/:id", ...)` between the existing `GET /` and `GET /:id/topics` handlers: 300 + 301 + ```typescript 302 + // After .get("/", ...) and before .get("/:id/topics", ...) 303 + .get("/:id", async (c) => { 304 + const { id } = c.req.param(); 305 + 306 + const boardId = parseBigIntParam(id); 307 + if (boardId === null) { 308 + return c.json({ error: "Invalid board ID format" }, 400); 309 + } 310 + 311 + try { 312 + const [board] = await ctx.db 313 + .select() 314 + .from(boards) 315 + .where(eq(boards.id, boardId)) 316 + .limit(1); 317 + 318 + if (!board) { 319 + return c.json({ error: "Board not found" }, 404); 320 + } 321 + 322 + return c.json(serializeBoard(board)); 323 + } catch (error) { 324 + console.error("Failed to query board", { 325 + operation: "GET /api/boards/:id", 326 + boardId: id, 327 + error: error instanceof Error ? error.message : String(error), 328 + }); 329 + return c.json( 330 + { error: "Failed to retrieve board. Please try again later." }, 331 + 500 332 + ); 333 + } 334 + }) 335 + ``` 336 + 337 + **Step 4: Run tests to verify they pass** 338 + 339 + ```bash 340 + export PATH="$PWD/.devenv/profile/bin:$PATH" 341 + pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/boards.test.ts 342 + ``` 343 + Expected: All tests PASS (existing + 4 new) 344 + 345 + **Step 5: Commit** 346 + 347 + ```bash 348 + git add apps/appview/src/routes/boards.ts apps/appview/src/routes/__tests__/boards.test.ts 349 + git commit -m "feat(appview): add GET /api/boards/:id endpoint" 350 + ``` 351 + 352 + --- 353 + 354 + ## Task 4: AppView Pagination for `GET /api/boards/:id/topics` 355 + 356 + **Files:** 357 + - Modify: `apps/appview/src/routes/boards.ts` 358 + - Modify: `apps/appview/src/routes/__tests__/boards.test.ts` 359 + 360 + **Step 1: Write the failing tests** 361 + 362 + In `apps/appview/src/routes/__tests__/boards.test.ts`, find the `describe("GET /api/boards/:id/topics", ...)` block. Add these tests inside it (after the existing tests): 363 + 364 + ```typescript 365 + it("returns total, offset, and limit in response", async () => { 366 + const res = await app.request(`/api/boards/${boardId}/topics`); 367 + expect(res.status).toBe(200); 368 + const data = await res.json(); 369 + expect(typeof data.total).toBe("number"); 370 + expect(typeof data.offset).toBe("number"); 371 + expect(typeof data.limit).toBe("number"); 372 + expect(data.offset).toBe(0); 373 + expect(data.total).toBe(2); 374 + }); 375 + 376 + it("applies offset and limit query params", async () => { 377 + const res = await app.request(`/api/boards/${boardId}/topics?offset=1&limit=1`); 378 + expect(res.status).toBe(200); 379 + const data = await res.json(); 380 + expect(data.topics).toHaveLength(1); 381 + expect(data.topics[0].text).toBe("First topic"); // offset=1 skips the newest 382 + expect(data.offset).toBe(1); 383 + expect(data.limit).toBe(1); 384 + expect(data.total).toBe(2); 385 + }); 386 + 387 + it("returns empty topics array when offset exceeds total", async () => { 388 + const res = await app.request(`/api/boards/${boardId}/topics?offset=100`); 389 + expect(res.status).toBe(200); 390 + const data = await res.json(); 391 + expect(data.topics).toHaveLength(0); 392 + expect(data.total).toBe(2); // total still reflects full count 393 + }); 394 + 395 + it("clamps limit to max 100", async () => { 396 + const res = await app.request(`/api/boards/${boardId}/topics?limit=999`); 397 + expect(res.status).toBe(200); 398 + const data = await res.json(); 399 + expect(data.limit).toBe(100); 400 + }); 401 + 402 + it("defaults to limit=25 when not specified", async () => { 403 + const res = await app.request(`/api/boards/${boardId}/topics`); 404 + expect(res.status).toBe(200); 405 + const data = await res.json(); 406 + expect(data.limit).toBe(25); 407 + }); 408 + ``` 409 + 410 + **Step 2: Run tests to verify they fail** 411 + 412 + ```bash 413 + export PATH="$PWD/.devenv/profile/bin:$PATH" 414 + pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/boards.test.ts 415 + ``` 416 + Expected: 5 new pagination tests FAIL 417 + 418 + **Step 3: Implement pagination** 419 + 420 + Open `apps/appview/src/routes/boards.ts`. Make these changes: 421 + 422 + 1. Add `count` to the drizzle-orm import: 423 + ```typescript 424 + import { asc, eq, and, desc, isNull, count } from "drizzle-orm"; 425 + ``` 426 + 427 + 2. Replace the existing `GET /:id/topics` handler body (after the board-exists check) with: 428 + 429 + ```typescript 430 + .get("/:id/topics", async (c) => { 431 + const { id } = c.req.param(); 432 + 433 + const boardId = parseBigIntParam(id); 434 + if (boardId === null) { 435 + return c.json({ error: "Invalid board ID format" }, 400); 436 + } 437 + 438 + // Parse and clamp pagination params 439 + const rawOffset = c.req.query("offset"); 440 + const rawLimit = c.req.query("limit"); 441 + const offset = Math.max(0, parseInt(rawOffset ?? "0", 10) || 0); 442 + const limit = Math.min(100, Math.max(1, parseInt(rawLimit ?? "25", 10) || 25)); 443 + 444 + try { 445 + const [board] = await ctx.db 446 + .select() 447 + .from(boards) 448 + .where(eq(boards.id, boardId)) 449 + .limit(1); 450 + 451 + if (!board) { 452 + return c.json({ error: "Board not found" }, 404); 453 + } 454 + 455 + const filter = and( 456 + eq(posts.boardId, boardId), 457 + isNull(posts.rootPostId), 458 + eq(posts.deleted, false) 459 + ); 460 + 461 + const [countRow, topicResults] = await Promise.all([ 462 + ctx.db.select({ total: count() }).from(posts).where(filter), 463 + ctx.db 464 + .select({ post: posts, author: users }) 465 + .from(posts) 466 + .leftJoin(users, eq(posts.did, users.did)) 467 + .where(filter) 468 + .orderBy(desc(posts.createdAt)) 469 + .offset(offset) 470 + .limit(limit), 471 + ]); 472 + 473 + const total = countRow[0]?.total ?? 0; 474 + 475 + return c.json({ 476 + topics: topicResults.map(({ post, author }) => 477 + serializePost(post, author) 478 + ), 479 + total, 480 + offset, 481 + limit, 482 + }); 483 + } catch (error) { 484 + console.error("Failed to query board topics", { 485 + operation: "GET /api/boards/:id/topics", 486 + boardId: id, 487 + error: error instanceof Error ? error.message : String(error), 488 + }); 489 + 490 + return c.json( 491 + { error: "Failed to retrieve topics. Please try again later." }, 492 + 500 493 + ); 494 + } 495 + }) 496 + ``` 497 + 498 + **Step 4: Run all AppView tests to verify no regressions** 499 + 500 + ```bash 501 + export PATH="$PWD/.devenv/profile/bin:$PATH" 502 + pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/boards.test.ts 503 + ``` 504 + Expected: All tests PASS 505 + 506 + **Step 5: Commit** 507 + 508 + ```bash 509 + git add apps/appview/src/routes/boards.ts apps/appview/src/routes/__tests__/boards.test.ts 510 + git commit -m "feat(appview): add pagination to GET /api/boards/:id/topics" 511 + ``` 512 + 513 + --- 514 + 515 + ## Task 5: AppView `GET /api/categories/:id` 516 + 517 + **Files:** 518 + - Modify: `apps/appview/src/routes/categories.ts` 519 + - Modify: `apps/appview/src/routes/__tests__/categories.test.ts` 520 + 521 + **Step 1: Write the failing tests** 522 + 523 + Open `apps/appview/src/routes/__tests__/categories.test.ts`. Add a new `describe` block at the end of the file: 524 + 525 + ```typescript 526 + describe("GET /:id", () => { 527 + let ctx: TestContext; 528 + let app: Hono; 529 + let categoryId: bigint; 530 + let cleanedUp = false; 531 + 532 + beforeEach(async () => { 533 + ctx = await createTestContext(); 534 + app = new Hono().route("/", createCategoriesRoutes(ctx)); 535 + cleanedUp = false; 536 + 537 + const [testForum] = await ctx.db 538 + .select() 539 + .from(forums) 540 + .where(eq(forums.did, ctx.config.forumDid)) 541 + .limit(1); 542 + 543 + const [cat] = await ctx.db.insert(categories).values({ 544 + did: ctx.config.forumDid, 545 + rkey: "cat-single", 546 + cid: "bafycatsingle", 547 + name: "Tech Talk", 548 + description: "All things technical", 549 + sortOrder: 1, 550 + forumId: testForum.id, 551 + createdAt: new Date(), 552 + indexedAt: new Date(), 553 + }).returning(); 554 + 555 + categoryId = cat.id; 556 + }); 557 + 558 + afterEach(async () => { 559 + if (!cleanedUp) await ctx.cleanup(); 560 + }); 561 + 562 + it("returns category by ID", async () => { 563 + const res = await app.request(`/${categoryId}`); 564 + expect(res.status).toBe(200); 565 + const data = await res.json(); 566 + expect(data.name).toBe("Tech Talk"); 567 + expect(data.description).toBe("All things technical"); 568 + expect(typeof data.id).toBe("string"); 569 + expect(data.id).toMatch(/^\d+$/); 570 + }); 571 + 572 + it("returns 404 for non-existent category", async () => { 573 + const res = await app.request("/999999"); 574 + expect(res.status).toBe(404); 575 + const data = await res.json(); 576 + expect(data.error).toBe("Category not found"); 577 + }); 578 + 579 + it("returns 400 for non-integer ID", async () => { 580 + const res = await app.request("/not-a-number"); 581 + expect(res.status).toBe(400); 582 + const data = await res.json(); 583 + expect(data.error).toBe("Invalid category ID format"); 584 + }); 585 + 586 + it("returns 500 on database error", async () => { 587 + await ctx.cleanup(); 588 + cleanedUp = true; 589 + const res = await app.request(`/${categoryId}`); 590 + expect(res.status).toBe(500); 591 + const data = await res.json(); 592 + expect(data.error).toBe("Failed to retrieve category. Please try again later."); 593 + }); 594 + }); 595 + ``` 596 + 597 + Note: add `eq` to imports at the top of the test file if not already present: 598 + ```typescript 599 + import { categories, forums } from "@atbb/db"; 600 + import { eq } from "drizzle-orm"; 601 + ``` 602 + 603 + **Step 2: Run tests to verify they fail** 604 + 605 + ```bash 606 + export PATH="$PWD/.devenv/profile/bin:$PATH" 607 + pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/categories.test.ts 608 + ``` 609 + Expected: 4 new tests FAIL 610 + 611 + **Step 3: Implement `GET /api/categories/:id`** 612 + 613 + Open `apps/appview/src/routes/categories.ts`. Add `.get("/:id", ...)` BEFORE the existing `.get("/:id/boards", ...)` handler: 614 + 615 + ```typescript 616 + // Add between .get("/", ...) and .get("/:id/boards", ...) 617 + .get("/:id", async (c) => { 618 + const { id } = c.req.param(); 619 + 620 + const categoryId = parseBigIntParam(id); 621 + if (categoryId === null) { 622 + return c.json({ error: "Invalid category ID format" }, 400); 623 + } 624 + 625 + try { 626 + const [category] = await ctx.db 627 + .select() 628 + .from(categories) 629 + .where(eq(categories.id, categoryId)) 630 + .limit(1); 631 + 632 + if (!category) { 633 + return c.json({ error: "Category not found" }, 404); 634 + } 635 + 636 + return c.json(serializeCategory(category)); 637 + } catch (error) { 638 + console.error("Failed to query category", { 639 + operation: "GET /api/categories/:id", 640 + categoryId: id, 641 + error: error instanceof Error ? error.message : String(error), 642 + }); 643 + return c.json( 644 + { error: "Failed to retrieve category. Please try again later." }, 645 + 500 646 + ); 647 + } 648 + }) 649 + ``` 650 + 651 + **Step 4: Run all category tests** 652 + 653 + ```bash 654 + export PATH="$PWD/.devenv/profile/bin:$PATH" 655 + pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/categories.test.ts 656 + ``` 657 + Expected: All tests PASS 658 + 659 + **Step 5: Commit** 660 + 661 + ```bash 662 + git add apps/appview/src/routes/categories.ts apps/appview/src/routes/__tests__/categories.test.ts 663 + git commit -m "feat(appview): add GET /api/categories/:id endpoint" 664 + ``` 665 + 666 + --- 667 + 668 + ## Task 6: Web `boards.tsx` Full Implementation 669 + 670 + **Files:** 671 + - Modify: `apps/web/src/routes/boards.tsx` (full replacement) 672 + - Create: `apps/web/src/routes/__tests__/boards.test.tsx` 673 + - Modify: `apps/web/src/routes/__tests__/stubs.test.tsx` (remove boards stubs) 674 + 675 + ### Step 1: Write the comprehensive test file 676 + 677 + Create `apps/web/src/routes/__tests__/boards.test.tsx`: 678 + 679 + ```typescript 680 + import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 681 + 682 + const mockFetch = vi.fn(); 683 + 684 + describe("createBoardsRoutes", () => { 685 + beforeEach(() => { 686 + vi.stubGlobal("fetch", mockFetch); 687 + vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 688 + vi.resetModules(); 689 + }); 690 + 691 + afterEach(() => { 692 + vi.unstubAllGlobals(); 693 + vi.unstubAllEnvs(); 694 + mockFetch.mockReset(); 695 + }); 696 + 697 + function mockResponse(body: unknown, ok = true, status = 200) { 698 + return { 699 + ok, 700 + status, 701 + statusText: ok ? "OK" : "Error", 702 + json: () => Promise.resolve(body), 703 + }; 704 + } 705 + 706 + const boardData = { 707 + id: "42", 708 + did: "did:plc:forum", 709 + name: "General Discussion", 710 + description: "A place for general chat", 711 + slug: null, 712 + sortOrder: 1, 713 + categoryId: "10", 714 + categoryUri: "at://did:plc:forum/space.atbb.forum.category/cat1", 715 + createdAt: "2026-01-01T00:00:00.000Z", 716 + indexedAt: "2026-01-01T00:00:00.000Z", 717 + }; 718 + 719 + const categoryData = { 720 + id: "10", 721 + did: "did:plc:forum", 722 + name: "Announcements", 723 + description: null, 724 + slug: null, 725 + sortOrder: 1, 726 + }; 727 + 728 + const topicData = { 729 + id: "100", 730 + did: "did:plc:alice", 731 + rkey: "3lbk7abc", 732 + text: "Hello world! This is a long topic title that should be truncated after eighty characters for display", 733 + forumUri: "at://did:plc:forum/space.atbb.forum.forum/self", 734 + boardUri: "at://did:plc:forum/space.atbb.forum.board/board1", 735 + boardId: "42", 736 + parentPostId: null, 737 + createdAt: "2026-02-18T10:00:00.000Z", 738 + author: { did: "did:plc:alice", handle: "alice.bsky.social" }, 739 + }; 740 + 741 + const topicsResponse = { 742 + topics: [topicData], 743 + total: 1, 744 + offset: 0, 745 + limit: 25, 746 + }; 747 + 748 + // Helper: sets up mock fetch for a full-page request (no auth cookie) 749 + // Sequence: [board, topics, category] 750 + function setupSuccessfulFetch(overrides: { 751 + board?: object; 752 + topics?: object; 753 + category?: object; 754 + } = {}) { 755 + mockFetch.mockResolvedValueOnce(mockResponse(overrides.board ?? boardData)); 756 + mockFetch.mockResolvedValueOnce(mockResponse(overrides.topics ?? topicsResponse)); 757 + mockFetch.mockResolvedValueOnce(mockResponse(overrides.category ?? categoryData)); 758 + } 759 + 760 + async function loadBoardsRoutes() { 761 + const { createBoardsRoutes } = await import("../boards.js"); 762 + return createBoardsRoutes("http://localhost:3000"); 763 + } 764 + 765 + // --- Basic rendering --- 766 + 767 + it("returns 200 with board name in page title", async () => { 768 + setupSuccessfulFetch(); 769 + const routes = await loadBoardsRoutes(); 770 + const res = await routes.request("/boards/42"); 771 + expect(res.status).toBe(200); 772 + const html = await res.text(); 773 + expect(html).toContain("General Discussion — atBB Forum"); 774 + }); 775 + 776 + it("renders board name in page header", async () => { 777 + setupSuccessfulFetch(); 778 + const routes = await loadBoardsRoutes(); 779 + const res = await routes.request("/boards/42"); 780 + const html = await res.text(); 781 + expect(html).toContain("General Discussion"); 782 + }); 783 + 784 + it("renders board description", async () => { 785 + setupSuccessfulFetch(); 786 + const routes = await loadBoardsRoutes(); 787 + const res = await routes.request("/boards/42"); 788 + const html = await res.text(); 789 + expect(html).toContain("A place for general chat"); 790 + }); 791 + 792 + // --- Breadcrumbs --- 793 + 794 + it("renders breadcrumb with Home link", async () => { 795 + setupSuccessfulFetch(); 796 + const routes = await loadBoardsRoutes(); 797 + const res = await routes.request("/boards/42"); 798 + const html = await res.text(); 799 + expect(html).toContain('href="/"'); 800 + expect(html).toContain("Home"); 801 + }); 802 + 803 + it("renders breadcrumb with category name", async () => { 804 + setupSuccessfulFetch(); 805 + const routes = await loadBoardsRoutes(); 806 + const res = await routes.request("/boards/42"); 807 + const html = await res.text(); 808 + expect(html).toContain("Announcements"); 809 + }); 810 + 811 + it("renders breadcrumb with board name", async () => { 812 + setupSuccessfulFetch(); 813 + const routes = await loadBoardsRoutes(); 814 + const res = await routes.request("/boards/42"); 815 + const html = await res.text(); 816 + expect(html).toContain("breadcrumbs"); 817 + expect(html).toContain("General Discussion"); 818 + }); 819 + 820 + it("renders breadcrumb without category name when category fetch fails", async () => { 821 + // board + topics succeed, category fails 822 + mockFetch.mockResolvedValueOnce(mockResponse(boardData)); 823 + mockFetch.mockResolvedValueOnce(mockResponse(topicsResponse)); 824 + mockFetch.mockResolvedValueOnce({ ok: false, status: 500, statusText: "Error" }); 825 + const routes = await loadBoardsRoutes(); 826 + const res = await routes.request("/boards/42"); 827 + // Page should still render with 200 — category failure is non-fatal 828 + expect(res.status).toBe(200); 829 + const html = await res.text(); 830 + expect(html).toContain("General Discussion"); 831 + }); 832 + 833 + // --- Topic list --- 834 + 835 + it("renders topic author handle", async () => { 836 + setupSuccessfulFetch(); 837 + const routes = await loadBoardsRoutes(); 838 + const res = await routes.request("/boards/42"); 839 + const html = await res.text(); 840 + expect(html).toContain("alice.bsky.social"); 841 + }); 842 + 843 + it("renders topic title as first 80 chars of text", async () => { 844 + setupSuccessfulFetch(); 845 + const routes = await loadBoardsRoutes(); 846 + const res = await routes.request("/boards/42"); 847 + const html = await res.text(); 848 + // Full text is 97 chars — should be truncated with ellipsis 849 + expect(html).toContain("Hello world! This is a long topic title that should be truncated after eighty chara…"); 850 + expect(html).not.toContain("for display"); // truncated before this 851 + }); 852 + 853 + it("renders topic link to /topics/:id", async () => { 854 + setupSuccessfulFetch(); 855 + const routes = await loadBoardsRoutes(); 856 + const res = await routes.request("/boards/42"); 857 + const html = await res.text(); 858 + expect(html).toContain('href="/topics/100"'); 859 + }); 860 + 861 + it("renders relative date for topic", async () => { 862 + setupSuccessfulFetch(); 863 + const routes = await loadBoardsRoutes(); 864 + const res = await routes.request("/boards/42"); 865 + const html = await res.text(); 866 + // Date should be rendered as relative (e.g. "N days ago" or "just now") 867 + expect(html).toMatch(/ago|just now|\d{4}-\d{2}-\d{2}/); 868 + }); 869 + 870 + it("shows empty state when board has no topics", async () => { 871 + setupSuccessfulFetch({ 872 + topics: { topics: [], total: 0, offset: 0, limit: 25 }, 873 + }); 874 + const routes = await loadBoardsRoutes(); 875 + const res = await routes.request("/boards/42"); 876 + const html = await res.text(); 877 + expect(html).toContain("No topics yet"); 878 + }); 879 + 880 + // --- New Topic button --- 881 + 882 + it("shows 'Log in to start a topic' when unauthenticated", async () => { 883 + setupSuccessfulFetch(); 884 + const routes = await loadBoardsRoutes(); 885 + // No cookie → unauthenticated 886 + const res = await routes.request("/boards/42"); 887 + const html = await res.text(); 888 + expect(html).toContain("Log in"); 889 + expect(html).toContain("to start a topic"); 890 + expect(html).not.toContain("New Topic"); 891 + }); 892 + 893 + it("shows 'New Topic' link with boardId when authenticated", async () => { 894 + // Authenticated: session fetch first, then board+topics+category 895 + mockFetch.mockResolvedValueOnce(mockResponse({ 896 + authenticated: true, 897 + did: "did:plc:alice", 898 + handle: "alice.bsky.social", 899 + })); 900 + setupSuccessfulFetch(); 901 + const routes = await loadBoardsRoutes(); 902 + const res = await routes.request("/boards/42", { 903 + headers: { cookie: "atbb_session=token" }, 904 + }); 905 + const html = await res.text(); 906 + expect(html).toContain("New Topic"); 907 + expect(html).toContain('href="/new-topic?boardId=42"'); 908 + expect(html).not.toContain("Log in to start"); 909 + }); 910 + 911 + // --- Pagination --- 912 + 913 + it("shows Load More button when more topics exist", async () => { 914 + setupSuccessfulFetch({ 915 + topics: { topics: [topicData], total: 50, offset: 0, limit: 25 }, 916 + }); 917 + const routes = await loadBoardsRoutes(); 918 + const res = await routes.request("/boards/42"); 919 + const html = await res.text(); 920 + expect(html).toContain("Load More"); 921 + expect(html).toContain("hx-get"); 922 + expect(html).toContain("offset=1"); // next offset = 0 + 1 topic 923 + }); 924 + 925 + it("does not show Load More button when all topics are loaded", async () => { 926 + setupSuccessfulFetch({ 927 + topics: { topics: [topicData], total: 1, offset: 0, limit: 25 }, 928 + }); 929 + const routes = await loadBoardsRoutes(); 930 + const res = await routes.request("/boards/42"); 931 + const html = await res.text(); 932 + expect(html).not.toContain("Load More"); 933 + }); 934 + 935 + // --- HTMX partial --- 936 + 937 + it("returns topic rows only for HTMX partial request", async () => { 938 + // HTMX partial: only topics fetch (no getSession, no board, no category) 939 + mockFetch.mockResolvedValueOnce(mockResponse({ 940 + topics: [topicData], 941 + total: 2, 942 + offset: 1, 943 + limit: 25, 944 + })); 945 + const routes = await loadBoardsRoutes(); 946 + const res = await routes.request("/boards/42?offset=1", { 947 + headers: { "HX-Request": "true" }, 948 + }); 949 + expect(res.status).toBe(200); 950 + const html = await res.text(); 951 + // Has topic content but no full page chrome 952 + expect(html).toContain("alice.bsky.social"); 953 + expect(html).not.toContain("<html"); 954 + expect(html).not.toContain("site-header"); 955 + }); 956 + 957 + it("HTMX partial: shows Load More button when more topics remain", async () => { 958 + mockFetch.mockResolvedValueOnce(mockResponse({ 959 + topics: [topicData], 960 + total: 10, 961 + offset: 1, 962 + limit: 25, 963 + })); 964 + const routes = await loadBoardsRoutes(); 965 + const res = await routes.request("/boards/42?offset=1", { 966 + headers: { "HX-Request": "true" }, 967 + }); 968 + const html = await res.text(); 969 + expect(html).toContain("Load More"); 970 + }); 971 + 972 + it("HTMX partial: no Load More button when on last page", async () => { 973 + mockFetch.mockResolvedValueOnce(mockResponse({ 974 + topics: [topicData], 975 + total: 2, 976 + offset: 1, 977 + limit: 25, 978 + })); 979 + const routes = await loadBoardsRoutes(); 980 + const res = await routes.request("/boards/42?offset=1", { 981 + headers: { "HX-Request": "true" }, 982 + }); 983 + const html = await res.text(); 984 + expect(html).not.toContain("Load More"); 985 + }); 986 + 987 + // --- Error handling --- 988 + 989 + it("returns 404 HTML page when board not found", async () => { 990 + // AppView returns 404 for board 991 + mockFetch.mockResolvedValueOnce({ ok: false, status: 404, statusText: "Not Found" }); 992 + // Topics fetch also mocked (parallel) but won't matter since board fails first 993 + mockFetch.mockResolvedValueOnce(mockResponse(topicsResponse)); 994 + const routes = await loadBoardsRoutes(); 995 + const res = await routes.request("/boards/999"); 996 + expect(res.status).toBe(404); 997 + const html = await res.text(); 998 + expect(html).toContain("Not Found"); 999 + expect(html).toContain("<html"); 1000 + }); 1001 + 1002 + it("returns 503 on AppView network error", async () => { 1003 + mockFetch.mockRejectedValueOnce(new Error("AppView network error: fetch failed")); 1004 + mockFetch.mockResolvedValueOnce(mockResponse(topicsResponse)); 1005 + const routes = await loadBoardsRoutes(); 1006 + const res = await routes.request("/boards/42"); 1007 + expect(res.status).toBe(503); 1008 + const html = await res.text(); 1009 + expect(html).toContain("error-display"); 1010 + expect(html).toContain("unavailable"); 1011 + }); 1012 + 1013 + it("returns 500 on AppView server error", async () => { 1014 + mockFetch.mockResolvedValueOnce({ ok: false, status: 500, statusText: "Internal Server Error" }); 1015 + mockFetch.mockResolvedValueOnce(mockResponse(topicsResponse)); 1016 + const routes = await loadBoardsRoutes(); 1017 + const res = await routes.request("/boards/42"); 1018 + expect(res.status).toBe(500); 1019 + const html = await res.text(); 1020 + expect(html).toContain("error-display"); 1021 + expect(html).toContain("Something went wrong"); 1022 + }); 1023 + 1024 + it("re-throws TypeError from API response (programming error)", async () => { 1025 + // Board fetch returns null — accessing null.name throws TypeError 1026 + mockFetch.mockResolvedValueOnce(mockResponse(null)); 1027 + mockFetch.mockResolvedValueOnce(mockResponse(topicsResponse)); 1028 + const routes = await loadBoardsRoutes(); 1029 + const res = await routes.request("/boards/42"); 1030 + // TypeError is re-thrown — Hono's default handler returns 500 text/plain 1031 + expect(res.status).toBe(500); 1032 + expect(res.headers.get("content-type")).not.toContain("text/html"); 1033 + }); 1034 + }); 1035 + ``` 1036 + 1037 + **Step 2: Run tests to verify they fail** 1038 + 1039 + ```bash 1040 + export PATH="$PWD/.devenv/profile/bin:$PATH" 1041 + pnpm --filter @atbb/web exec vitest run src/routes/__tests__/boards.test.tsx 1042 + ``` 1043 + Expected: Most tests FAIL because boards.tsx is still a stub 1044 + 1045 + **Step 3: Implement the full `boards.tsx`** 1046 + 1047 + Replace the entire content of `apps/web/src/routes/boards.tsx`: 1048 + 1049 + ```tsx 1050 + import { Hono } from "hono"; 1051 + import { BaseLayout } from "../layouts/base.js"; 1052 + import { PageHeader, EmptyState, ErrorDisplay } from "../components/index.js"; 1053 + import { fetchApi } from "../lib/api.js"; 1054 + import { getSession } from "../lib/session.js"; 1055 + import { 1056 + isProgrammingError, 1057 + isNetworkError, 1058 + isNotFoundError, 1059 + } from "../lib/errors.js"; 1060 + import { timeAgo } from "../lib/time.js"; 1061 + 1062 + // API response type shapes 1063 + interface BoardResponse { 1064 + id: string; 1065 + did: string; 1066 + name: string; 1067 + description: string | null; 1068 + slug: string | null; 1069 + sortOrder: number | null; 1070 + categoryId: string | null; 1071 + categoryUri: string; 1072 + createdAt: string | null; 1073 + indexedAt: string | null; 1074 + } 1075 + 1076 + interface CategoryResponse { 1077 + id: string; 1078 + name: string; 1079 + } 1080 + 1081 + interface TopicAuthor { 1082 + did: string; 1083 + handle: string; 1084 + } 1085 + 1086 + interface TopicResponse { 1087 + id: string; 1088 + did: string; 1089 + text: string; 1090 + createdAt: string | null; 1091 + author: TopicAuthor | null; 1092 + } 1093 + 1094 + interface TopicsListResponse { 1095 + topics: TopicResponse[]; 1096 + total: number; 1097 + offset: number; 1098 + limit: number; 1099 + } 1100 + 1101 + const TOPICS_PER_PAGE = 25; 1102 + 1103 + /** Extract the first 80 characters as a display title from raw post text. */ 1104 + function extractTitle(text: string): string { 1105 + const firstLine = text.split("\n")[0] ?? text; 1106 + if (firstLine.length <= 80) return firstLine; 1107 + return firstLine.slice(0, 77) + "…"; 1108 + } 1109 + 1110 + /** Topic rows for both full-page and HTMX partial responses. */ 1111 + function TopicRows({ 1112 + topics, 1113 + boardId, 1114 + nextOffset, 1115 + total, 1116 + }: { 1117 + topics: TopicResponse[]; 1118 + boardId: string; 1119 + nextOffset: number; 1120 + total: number; 1121 + }) { 1122 + const hasMore = nextOffset < total; 1123 + return ( 1124 + <> 1125 + {topics.map((topic) => ( 1126 + <a href={`/topics/${topic.id}`} class="topic-row" key={topic.id}> 1127 + <div class="topic-row__title">{extractTitle(topic.text)}</div> 1128 + <div class="topic-row__meta"> 1129 + <span class="topic-row__author"> 1130 + {topic.author?.handle ?? topic.did} 1131 + </span> 1132 + {topic.createdAt && ( 1133 + <span class="topic-row__date"> 1134 + {timeAgo(new Date(topic.createdAt))} 1135 + </span> 1136 + )} 1137 + </div> 1138 + </a> 1139 + ))} 1140 + {hasMore && ( 1141 + <button 1142 + class="load-more-btn" 1143 + hx-get={`/boards/${boardId}?offset=${nextOffset}`} 1144 + hx-swap="outerHTML" 1145 + hx-target="this" 1146 + > 1147 + Load More 1148 + </button> 1149 + )} 1150 + </> 1151 + ); 1152 + } 1153 + 1154 + export function createBoardsRoutes(appviewUrl: string) { 1155 + return new Hono().get("/boards/:id", async (c) => { 1156 + const { id } = c.req.param(); 1157 + 1158 + // Parse pagination offset (used for HTMX partial mode) 1159 + const rawOffset = c.req.query("offset"); 1160 + const offset = Math.max(0, parseInt(rawOffset ?? "0", 10) || 0); 1161 + 1162 + // HTMX partial mode: return topic rows only (triggered by Load More button) 1163 + const isHtmxRequest = c.req.header("HX-Request") === "true"; 1164 + if (isHtmxRequest) { 1165 + try { 1166 + const data = await fetchApi<TopicsListResponse>( 1167 + `/boards/${id}/topics?offset=${offset}&limit=${TOPICS_PER_PAGE}` 1168 + ); 1169 + return c.html( 1170 + <TopicRows 1171 + topics={data.topics} 1172 + boardId={id} 1173 + nextOffset={offset + data.topics.length} 1174 + total={data.total} 1175 + /> 1176 + ); 1177 + } catch (error) { 1178 + if (isProgrammingError(error)) throw error; 1179 + console.error("Failed to load more topics (HTMX partial)", { 1180 + operation: "GET /boards/:id (HTMX partial)", 1181 + boardId: id, 1182 + offset, 1183 + error: error instanceof Error ? error.message : String(error), 1184 + }); 1185 + // Graceful degradation: return empty fragment rather than breaking the page 1186 + return c.html(<></>); 1187 + } 1188 + } 1189 + 1190 + // Full-page mode 1191 + const auth = await getSession(appviewUrl, c.req.header("cookie")); 1192 + 1193 + // Stage 1: fetch board metadata and first page of topics in parallel 1194 + let board: BoardResponse; 1195 + let topicsData: TopicsListResponse; 1196 + try { 1197 + const [boardResult, topicsResult] = await Promise.all([ 1198 + fetchApi<BoardResponse>(`/boards/${id}`), 1199 + fetchApi<TopicsListResponse>( 1200 + `/boards/${id}/topics?offset=0&limit=${TOPICS_PER_PAGE}` 1201 + ), 1202 + ]); 1203 + board = boardResult; 1204 + topicsData = topicsResult; 1205 + } catch (error) { 1206 + if (isProgrammingError(error)) throw error; 1207 + if (isNotFoundError(error)) { 1208 + return c.html( 1209 + <BaseLayout title="Not Found — atBB Forum" auth={auth}> 1210 + <ErrorDisplay message="Board not found." /> 1211 + </BaseLayout>, 1212 + 404 1213 + ); 1214 + } 1215 + console.error("Failed to load board view (stage 1: board + topics)", { 1216 + operation: "GET /boards/:id", 1217 + boardId: id, 1218 + error: error instanceof Error ? error.message : String(error), 1219 + }); 1220 + const status = isNetworkError(error) ? 503 : 500; 1221 + const message = 1222 + status === 503 1223 + ? "The forum is temporarily unavailable. Please try again later." 1224 + : "Something went wrong loading this board. Please try again later."; 1225 + return c.html( 1226 + <BaseLayout title="Error — atBB Forum" auth={auth}> 1227 + <ErrorDisplay message={message} /> 1228 + </BaseLayout>, 1229 + status 1230 + ); 1231 + } 1232 + 1233 + // Stage 2: fetch category name for breadcrumbs (non-fatal if it fails) 1234 + let categoryName: string | null = null; 1235 + if (board.categoryId) { 1236 + try { 1237 + const cat = await fetchApi<CategoryResponse>( 1238 + `/categories/${board.categoryId}` 1239 + ); 1240 + categoryName = cat.name; 1241 + } catch (error) { 1242 + if (isProgrammingError(error)) throw error; 1243 + console.error("Failed to load category for breadcrumb", { 1244 + operation: "GET /boards/:id (stage 2: category)", 1245 + boardId: id, 1246 + categoryId: board.categoryId, 1247 + error: error instanceof Error ? error.message : String(error), 1248 + }); 1249 + // Non-fatal: page renders without category in breadcrumbs 1250 + } 1251 + } 1252 + 1253 + const nextOffset = topicsData.topics.length; 1254 + 1255 + return c.html( 1256 + <BaseLayout title={`${board.name} — atBB Forum`} auth={auth}> 1257 + {/* Breadcrumb navigation */} 1258 + <nav class="breadcrumbs" aria-label="breadcrumb"> 1259 + <a href="/" class="breadcrumbs__link"> 1260 + Home 1261 + </a> 1262 + {categoryName && ( 1263 + <> 1264 + <span class="breadcrumbs__sep"> / </span> 1265 + <span class="breadcrumbs__text">{categoryName}</span> 1266 + </> 1267 + )} 1268 + <span class="breadcrumbs__sep"> / </span> 1269 + <span class="breadcrumbs__current">{board.name}</span> 1270 + </nav> 1271 + 1272 + <PageHeader 1273 + title={board.name} 1274 + description={board.description ?? undefined} 1275 + /> 1276 + 1277 + {/* New Topic action */} 1278 + {auth?.authenticated ? ( 1279 + <a href={`/new-topic?boardId=${id}`} class="new-topic-btn"> 1280 + + New Topic 1281 + </a> 1282 + ) : ( 1283 + <p class="new-topic-hint"> 1284 + <a href="/login">Log in</a> to start a topic. 1285 + </p> 1286 + )} 1287 + 1288 + {/* Topic list */} 1289 + <div class="topic-list"> 1290 + {topicsData.topics.length === 0 ? ( 1291 + <EmptyState message="No topics yet." /> 1292 + ) : ( 1293 + <TopicRows 1294 + topics={topicsData.topics} 1295 + boardId={id} 1296 + nextOffset={nextOffset} 1297 + total={topicsData.total} 1298 + /> 1299 + )} 1300 + </div> 1301 + </BaseLayout> 1302 + ); 1303 + }); 1304 + } 1305 + ``` 1306 + 1307 + **Step 4: Run the comprehensive tests** 1308 + 1309 + ```bash 1310 + export PATH="$PWD/.devenv/profile/bin:$PATH" 1311 + pnpm --filter @atbb/web exec vitest run src/routes/__tests__/boards.test.tsx 1312 + ``` 1313 + Expected: All tests PASS (or very close — fix any minor issues) 1314 + 1315 + **Step 5: Remove boards stubs from `stubs.test.tsx`** 1316 + 1317 + Open `apps/web/src/routes/__tests__/stubs.test.tsx`. Delete the following tests (they are now covered by boards.test.tsx): 1318 + - `"GET /boards/:id returns 200 with board title"` 1319 + - `"GET /boards/:id shows 'Log in to start a topic' when unauthenticated"` 1320 + - `"GET /boards/:id shows 'Start a new topic' link when authenticated"` 1321 + 1322 + Keep all other tests (login, topics, new-topic, home). 1323 + 1324 + **Step 6: Run full web test suite** 1325 + 1326 + ```bash 1327 + export PATH="$PWD/.devenv/profile/bin:$PATH" 1328 + pnpm --filter @atbb/web exec vitest run 1329 + ``` 1330 + Expected: All tests PASS, no regressions 1331 + 1332 + **Step 7: Commit** 1333 + 1334 + ```bash 1335 + git add apps/web/src/routes/boards.tsx \ 1336 + apps/web/src/routes/__tests__/boards.test.tsx \ 1337 + apps/web/src/routes/__tests__/stubs.test.tsx 1338 + git commit -m "feat(web): implement board view with topic listing and HTMX pagination" 1339 + ``` 1340 + 1341 + --- 1342 + 1343 + ## Task 7: Bruno Collections 1344 + 1345 + **Files:** 1346 + - Create: `bruno/AppView API/Boards/Get Board.bru` 1347 + - Create: `bruno/AppView API/Categories/Get Category.bru` 1348 + - Modify: `bruno/AppView API/Boards/Get Board Topics.bru` 1349 + 1350 + **Step 1: Create `Get Board.bru`** 1351 + 1352 + Create `bruno/AppView API/Boards/Get Board.bru`: 1353 + 1354 + ``` 1355 + meta { 1356 + name: Get Board 1357 + type: http 1358 + seq: 3 1359 + } 1360 + 1361 + get { 1362 + url: {{appview_url}}/api/boards/1 1363 + } 1364 + 1365 + assert { 1366 + res.status: eq 200 1367 + res.body.id: isDefined 1368 + res.body.name: isDefined 1369 + } 1370 + 1371 + docs { 1372 + Returns details of a single board by ID. 1373 + 1374 + Path parameters: 1375 + - id: Board ID (numeric string) 1376 + 1377 + Returns: 1378 + { 1379 + "id": "42", 1380 + "did": "did:plc:forum", 1381 + "name": "General Discussion", 1382 + "description": "A place for chat" | null, 1383 + "slug": "general" | null, 1384 + "sortOrder": 1 | null, 1385 + "categoryId": "10", 1386 + "categoryUri": "at://did:plc:forum/space.atbb.forum.category/rkey", 1387 + "createdAt": "2026-01-01T00:00:00.000Z", 1388 + "indexedAt": "2026-01-01T00:00:00.000Z" 1389 + } 1390 + 1391 + Error codes: 1392 + - 400: Invalid board ID format (non-integer) 1393 + - 404: Board not found 1394 + - 500: Server error 1395 + } 1396 + ``` 1397 + 1398 + **Step 2: Create `Get Category.bru`** 1399 + 1400 + Create `bruno/AppView API/Categories/Get Category.bru`: 1401 + 1402 + ``` 1403 + meta { 1404 + name: Get Category 1405 + type: http 1406 + seq: 3 1407 + } 1408 + 1409 + get { 1410 + url: {{appview_url}}/api/categories/1 1411 + } 1412 + 1413 + assert { 1414 + res.status: eq 200 1415 + res.body.id: isDefined 1416 + res.body.name: isDefined 1417 + } 1418 + 1419 + docs { 1420 + Returns details of a single category by ID. 1421 + 1422 + Path parameters: 1423 + - id: Category ID (numeric string) 1424 + 1425 + Returns: 1426 + { 1427 + "id": "10", 1428 + "did": "did:plc:forum", 1429 + "name": "Announcements", 1430 + "description": "Official announcements" | null, 1431 + "slug": "announcements" | null, 1432 + "sortOrder": 1 | null, 1433 + "forumId": "1", 1434 + "createdAt": "2026-01-01T00:00:00.000Z", 1435 + "indexedAt": "2026-01-01T00:00:00.000Z" 1436 + } 1437 + 1438 + Error codes: 1439 + - 400: Invalid category ID format (non-integer) 1440 + - 404: Category not found 1441 + - 500: Server error 1442 + } 1443 + ``` 1444 + 1445 + **Step 3: Update `Get Board Topics.bru` with pagination params** 1446 + 1447 + Open `bruno/AppView API/Boards/Get Board Topics.bru` and update to: 1448 + 1449 + ``` 1450 + meta { 1451 + name: Get Board Topics 1452 + type: http 1453 + seq: 2 1454 + } 1455 + 1456 + get { 1457 + url: {{appview_url}}/api/boards/1/topics 1458 + } 1459 + 1460 + params:query { 1461 + offset: 0 1462 + limit: 25 1463 + } 1464 + 1465 + assert { 1466 + res.status: eq 200 1467 + res.body.topics: isDefined 1468 + res.body.total: isDefined 1469 + res.body.offset: isDefined 1470 + res.body.limit: isDefined 1471 + } 1472 + 1473 + docs { 1474 + Returns paginated topics (posts with NULL root) for a specific board, 1475 + sorted by creation time descending. 1476 + 1477 + Path parameters: 1478 + - id: Board ID (numeric) 1479 + 1480 + Query parameters: 1481 + - offset: Start index (default: 0) 1482 + - limit: Max results (default: 25, max: 100) 1483 + 1484 + Returns: 1485 + { 1486 + "topics": [ 1487 + { 1488 + "id": "123", 1489 + "did": "did:plc:...", 1490 + "rkey": "3lbk7...", 1491 + "text": "Topic text", 1492 + "forumUri": "at://...", 1493 + "boardUri": "at://...", 1494 + "boardId": "456", 1495 + "parentPostId": null, 1496 + "createdAt": "2026-02-13T00:00:00.000Z", 1497 + "author": { "did": "...", "handle": "..." } | null 1498 + } 1499 + ], 1500 + "total": 42, 1501 + "offset": 0, 1502 + "limit": 25 1503 + } 1504 + 1505 + Error codes: 1506 + - 400: Invalid board ID format 1507 + - 404: Board not found 1508 + - 500: Server error 1509 + } 1510 + ``` 1511 + 1512 + **Step 4: Commit** 1513 + 1514 + ```bash 1515 + git add "bruno/AppView API/Boards/Get Board.bru" \ 1516 + "bruno/AppView API/Categories/Get Category.bru" \ 1517 + "bruno/AppView API/Boards/Get Board Topics.bru" 1518 + git commit -m "docs(bruno): add Get Board, Get Category endpoints; update topics pagination" 1519 + ``` 1520 + 1521 + --- 1522 + 1523 + ## Task 8: Final Verification 1524 + 1525 + **Step 1: Run full test suite** 1526 + 1527 + ```bash 1528 + export PATH="$PWD/.devenv/profile/bin:$PATH" 1529 + pnpm test 1530 + ``` 1531 + Expected: All tests PASS 1532 + 1533 + **Step 2: Build to verify no TypeScript errors** 1534 + 1535 + ```bash 1536 + export PATH="$PWD/.devenv/profile/bin:$PATH" 1537 + pnpm build 1538 + ``` 1539 + Expected: Build succeeds without errors 1540 + 1541 + **Step 3: Update Linear issue** 1542 + 1543 + Mark ATB-28 as Done in Linear. Add a comment with: 1544 + - Files changed summary 1545 + - What was built (board view, HTMX load more, new AppView endpoints) 1546 + - Note: Bruno collections updated, full test coverage added 1547 + 1548 + **Step 4: Update plan doc** 1549 + 1550 + In `docs/atproto-forum-plan.md`, mark the Phase 4 "Category view" item as complete (note it's the board view per ATB-23 hierarchy).