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): forum homepage with live category and board listing (ATB-27) (#42)

* docs: add homepage design doc for ATB-27

* docs: add homepage implementation plan for ATB-27

* style: add homepage category and board grid CSS

* test: add failing tests for homepage route (ATB-27)

TDD setup: 11 tests covering forum name/description in title and header,
category section rendering, board cards with links and descriptions,
empty states, error handling (503 network, 500 API), and multi-category
layout. All tests intentionally fail against the placeholder route.

* feat: implement forum homepage with live API data (ATB-27)

Replaces placeholder homepage with real implementation that fetches forum
metadata, categories, and boards from the AppView API and renders them.
Also strengthens 500 error test to assert on message text.

* fix: use shared getSession in homepage route

* refactor: extract isNetworkError to shared web lib/errors.ts

* test: update stubs test to mock homepage API calls

The homepage now fetches /api/forum and /api/categories in parallel, so
the two GET / stub tests need mockResolvedValueOnce calls for both endpoints.

* fix: address code review feedback on homepage route (ATB-27)

- Add structured error logging to both catch blocks in home.tsx
- Add Stage 2 error tests (boards fetch) for 503 and 500 responses
- Mark forum homepage complete in atproto-forum-plan.md

* fix: re-throw programming errors in homepage catch blocks (ATB-27)

- Add isProgrammingError to apps/web/src/lib/errors.ts
- Guard both catch blocks with isProgrammingError re-throw before logging
- Add two tests verifying TypeError escapes catch blocks (stage 1 and stage 2)

authored by

Malpercio and committed by
GitHub
23a375fd 8dacd21a

+1261 -10
+42
apps/web/public/static/css/theme.css
··· 216 216 .loading-state.htmx-request { 217 217 opacity: 1; 218 218 } 219 + 220 + /* ─── Homepage ──────────────────────────────────────────────────────────── */ 221 + 222 + .category-section { 223 + margin-bottom: var(--space-xl); 224 + } 225 + 226 + .category-header { 227 + margin: 0 0 var(--space-md) 0; 228 + font-size: var(--font-size-lg); 229 + padding-bottom: var(--space-sm); 230 + border-bottom: var(--border-width) solid var(--color-border); 231 + } 232 + 233 + .board-grid { 234 + display: flex; 235 + flex-direction: column; 236 + gap: var(--space-md); 237 + } 238 + 239 + .board-card { 240 + display: block; 241 + text-decoration: none; 242 + color: inherit; 243 + } 244 + 245 + .board-card:hover .card { 246 + transform: translate(-2px, -2px); 247 + box-shadow: 8px 8px 0 var(--color-shadow); 248 + } 249 + 250 + .board-card__name { 251 + font-weight: var(--font-weight-bold); 252 + font-size: var(--font-size-base); 253 + margin: 0 0 var(--space-xs) 0; 254 + } 255 + 256 + .board-card__description { 257 + margin: 0; 258 + color: var(--color-text-muted); 259 + font-size: var(--font-size-sm); 260 + }
+26
apps/web/src/lib/errors.ts
··· 1 + /** 2 + * Returns true if the error is a programming bug (TypeError, ReferenceError, 3 + * SyntaxError). Callers should re-throw these so they propagate to the global 4 + * error handler rather than being silently swallowed. 5 + */ 6 + export function isProgrammingError(error: unknown): boolean { 7 + return ( 8 + error instanceof TypeError || 9 + error instanceof ReferenceError || 10 + error instanceof SyntaxError 11 + ); 12 + } 13 + 14 + /** 15 + * Returns true if the error is an AppView network error (AppView unreachable). 16 + * Callers should return 503 so the user knows to retry. 17 + * 18 + * fetchApi() throws with this prefix for connection-level failures: 19 + * "AppView network error: ..." 20 + */ 21 + export function isNetworkError(error: unknown): boolean { 22 + return ( 23 + error instanceof Error && 24 + error.message.startsWith("AppView network error:") 25 + ); 26 + }
+289
apps/web/src/routes/__tests__/home.test.tsx
··· 1 + import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 2 + 3 + const mockFetch = vi.fn(); 4 + 5 + describe("createHomeRoutes", () => { 6 + beforeEach(() => { 7 + vi.stubGlobal("fetch", mockFetch); 8 + vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 9 + vi.resetModules(); 10 + }); 11 + 12 + afterEach(() => { 13 + vi.unstubAllGlobals(); 14 + vi.unstubAllEnvs(); 15 + mockFetch.mockReset(); 16 + }); 17 + 18 + // Helper: build a mock fetch response 19 + function mockResponse(body: unknown, ok = true, status = 200) { 20 + return { 21 + ok, 22 + status, 23 + statusText: ok ? "OK" : "Error", 24 + json: () => Promise.resolve(body), 25 + }; 26 + } 27 + 28 + // Sets up the standard fetch sequence for unauthenticated requests (no cookie): 29 + // Since getSession short-circuits when no atbb_session= cookie is present, 30 + // the mock queue starts directly with the data fetches: 31 + // 1. GET /api/forum 32 + // 2. GET /api/categories 33 + // 3+. GET /api/categories/:id/boards (one per category) 34 + function setupSuccessfulFetch(options: { 35 + forum?: { name: string; description: string | null }; 36 + categories?: Array<{ id: string; name: string; description?: string | null }>; 37 + boardsPerCategory?: Record<string, Array<{ id: string; name: string; description?: string | null }>>; 38 + } = {}) { 39 + const forum = options.forum ?? { name: "Test Forum", description: "A test forum." }; 40 + const categories = options.categories ?? [ 41 + { id: "1", name: "General", description: "General chat" }, 42 + ]; 43 + const boardsPerCategory = options.boardsPerCategory ?? { 44 + "1": [{ id: "10", name: "Introductions", description: "Say hello!" }], 45 + }; 46 + 47 + // Call 1: GET /api/forum 48 + mockFetch.mockResolvedValueOnce(mockResponse({ 49 + id: "1", 50 + did: "did:plc:forum", 51 + name: forum.name, 52 + description: forum.description, 53 + indexedAt: "2025-01-01T00:00:00.000Z", 54 + })); 55 + // Call 2: GET /api/categories 56 + mockFetch.mockResolvedValueOnce(mockResponse({ 57 + categories: categories.map(c => ({ 58 + id: c.id, 59 + did: "did:plc:forum", 60 + name: c.name, 61 + description: c.description ?? null, 62 + slug: null, 63 + sortOrder: 1, 64 + forumId: "1", 65 + createdAt: "2025-01-01T00:00:00.000Z", 66 + indexedAt: "2025-01-01T00:00:00.000Z", 67 + })), 68 + })); 69 + // Call 3+: boards for each category 70 + for (const cat of categories) { 71 + const boards = boardsPerCategory[cat.id] ?? []; 72 + mockFetch.mockResolvedValueOnce(mockResponse({ 73 + boards: boards.map(b => ({ 74 + id: b.id, 75 + did: "did:plc:forum", 76 + name: b.name, 77 + description: b.description ?? null, 78 + slug: null, 79 + sortOrder: 1, 80 + categoryId: cat.id, 81 + categoryUri: `at://did:plc:forum/space.atbb.forum.category/${cat.id}`, 82 + createdAt: "2025-01-01T00:00:00.000Z", 83 + indexedAt: "2025-01-01T00:00:00.000Z", 84 + })), 85 + })); 86 + } 87 + } 88 + 89 + async function loadHomeRoutes() { 90 + const { createHomeRoutes } = await import("../home.js"); 91 + return createHomeRoutes("http://localhost:3000"); 92 + } 93 + 94 + it("returns 200 with forum name in page title", async () => { 95 + setupSuccessfulFetch({ forum: { name: "My Forum", description: null } }); 96 + const routes = await loadHomeRoutes(); 97 + const res = await routes.request("/"); 98 + expect(res.status).toBe(200); 99 + const html = await res.text(); 100 + expect(html).toContain("My Forum — atBB Forum"); 101 + }); 102 + 103 + it("renders forum name in page header", async () => { 104 + setupSuccessfulFetch({ forum: { name: "My Forum", description: null } }); 105 + const routes = await loadHomeRoutes(); 106 + const res = await routes.request("/"); 107 + const html = await res.text(); 108 + expect(html).toContain("My Forum"); 109 + }); 110 + 111 + it("renders forum description in page header", async () => { 112 + setupSuccessfulFetch({ forum: { name: "My Forum", description: "Welcome to my forum!" } }); 113 + const routes = await loadHomeRoutes(); 114 + const res = await routes.request("/"); 115 + const html = await res.text(); 116 + expect(html).toContain("Welcome to my forum!"); 117 + }); 118 + 119 + it("renders category section header", async () => { 120 + setupSuccessfulFetch({ 121 + categories: [{ id: "1", name: "General Discussion" }], 122 + }); 123 + const routes = await loadHomeRoutes(); 124 + const res = await routes.request("/"); 125 + const html = await res.text(); 126 + expect(html).toContain("General Discussion"); 127 + expect(html).toContain("category-header"); 128 + }); 129 + 130 + it("renders board card with name and link", async () => { 131 + setupSuccessfulFetch({ 132 + categories: [{ id: "1", name: "General" }], 133 + boardsPerCategory: { 134 + "1": [{ id: "42", name: "Introductions" }], 135 + }, 136 + }); 137 + const routes = await loadHomeRoutes(); 138 + const res = await routes.request("/"); 139 + const html = await res.text(); 140 + expect(html).toContain("Introductions"); 141 + expect(html).toContain('href="/boards/42"'); 142 + }); 143 + 144 + it("renders board description when present", async () => { 145 + setupSuccessfulFetch({ 146 + categories: [{ id: "1", name: "General" }], 147 + boardsPerCategory: { 148 + "1": [{ id: "42", name: "Introductions", description: "Say hello to everyone!" }], 149 + }, 150 + }); 151 + const routes = await loadHomeRoutes(); 152 + const res = await routes.request("/"); 153 + const html = await res.text(); 154 + expect(html).toContain("Say hello to everyone!"); 155 + }); 156 + 157 + it("shows empty state when there are no categories", async () => { 158 + setupSuccessfulFetch({ categories: [] }); 159 + const routes = await loadHomeRoutes(); 160 + const res = await routes.request("/"); 161 + const html = await res.text(); 162 + expect(html).toContain("No categories yet"); 163 + }); 164 + 165 + it("shows per-category empty state when category has no boards", async () => { 166 + setupSuccessfulFetch({ 167 + categories: [{ id: "1", name: "Empty Category" }], 168 + boardsPerCategory: { "1": [] }, 169 + }); 170 + const routes = await loadHomeRoutes(); 171 + const res = await routes.request("/"); 172 + const html = await res.text(); 173 + expect(html).toContain("Empty Category"); 174 + expect(html).toContain("No boards in this category yet"); 175 + }); 176 + 177 + it("returns 503 and error display on AppView network error", async () => { 178 + // forum fetch throws network error (no session fetch — no atbb_session cookie) 179 + mockFetch.mockRejectedValueOnce(new Error("AppView network error: fetch failed")); 180 + const routes = await loadHomeRoutes(); 181 + const res = await routes.request("/"); 182 + expect(res.status).toBe(503); 183 + const html = await res.text(); 184 + expect(html).toContain("error-display"); 185 + expect(html).toContain("unavailable"); 186 + }); 187 + 188 + it("returns 500 and error display on AppView API error", async () => { 189 + // forum fetch returns API error (no session fetch — no atbb_session cookie) 190 + mockFetch.mockResolvedValueOnce({ ok: false, status: 500, statusText: "Internal Server Error" }); 191 + const routes = await loadHomeRoutes(); 192 + const res = await routes.request("/"); 193 + expect(res.status).toBe(500); 194 + const html = await res.text(); 195 + expect(html).toContain("error-display"); 196 + expect(html).toContain("Something went wrong"); 197 + }); 198 + 199 + it("re-throws TypeError from stage 1 when API returns unexpected shape (programming error)", async () => { 200 + // forum fetch succeeds, categories fetch returns null — accessing null.categories throws TypeError 201 + mockFetch.mockResolvedValueOnce(mockResponse({ 202 + id: "1", did: "did:plc:forum", name: "Test Forum", description: null, indexedAt: "2025-01-01T00:00:00.000Z", 203 + })); 204 + mockFetch.mockResolvedValueOnce(mockResponse(null)); 205 + const routes = await loadHomeRoutes(); 206 + const res = await routes.request("/"); 207 + // TypeError is re-thrown — Hono's default handler returns 500 text/plain, 208 + // not our custom text/html ErrorDisplay (proving the catch block was bypassed) 209 + expect(res.status).toBe(500); 210 + expect(res.headers.get("content-type")).not.toContain("text/html"); 211 + }); 212 + 213 + it("re-throws TypeError from stage 2 when API returns unexpected shape (programming error)", async () => { 214 + // Stage 1 succeeds: forum + categories 215 + mockFetch.mockResolvedValueOnce(mockResponse({ 216 + id: "1", did: "did:plc:forum", name: "Test Forum", description: null, indexedAt: "2025-01-01T00:00:00.000Z", 217 + })); 218 + mockFetch.mockResolvedValueOnce(mockResponse({ 219 + categories: [{ id: "1", did: "did:plc:forum", name: "General", description: null, slug: null, sortOrder: 1 }], 220 + })); 221 + // Stage 2: boards fetch returns null — accessing null.boards in the .then() throws TypeError 222 + mockFetch.mockResolvedValueOnce(mockResponse(null)); 223 + const routes = await loadHomeRoutes(); 224 + const res = await routes.request("/"); 225 + // TypeError is re-thrown — Hono's default handler returns 500 text/plain, 226 + // not our custom text/html ErrorDisplay (proving the catch block was bypassed) 227 + expect(res.status).toBe(500); 228 + expect(res.headers.get("content-type")).not.toContain("text/html"); 229 + }); 230 + 231 + it("returns 503 and error display when boards fetch fails with network error (stage 2)", async () => { 232 + // Stage 1 succeeds: forum + categories 233 + mockFetch.mockResolvedValueOnce(mockResponse({ 234 + id: "1", did: "did:plc:forum", name: "Test Forum", description: null, indexedAt: "2025-01-01T00:00:00.000Z", 235 + })); 236 + mockFetch.mockResolvedValueOnce(mockResponse({ 237 + categories: [{ id: "1", did: "did:plc:forum", name: "General", description: null, slug: null, sortOrder: 1 }], 238 + })); 239 + // Stage 2 fails: boards fetch throws network error 240 + mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 241 + const routes = await loadHomeRoutes(); 242 + const res = await routes.request("/"); 243 + expect(res.status).toBe(503); 244 + const html = await res.text(); 245 + expect(html).toContain("error-display"); 246 + expect(html).toContain("unavailable"); 247 + }); 248 + 249 + it("returns 500 and error display when boards fetch fails with API error (stage 2)", async () => { 250 + // Stage 1 succeeds: forum + categories 251 + mockFetch.mockResolvedValueOnce(mockResponse({ 252 + id: "1", did: "did:plc:forum", name: "Test Forum", description: null, indexedAt: "2025-01-01T00:00:00.000Z", 253 + })); 254 + mockFetch.mockResolvedValueOnce(mockResponse({ 255 + categories: [{ id: "1", did: "did:plc:forum", name: "General", description: null, slug: null, sortOrder: 1 }], 256 + })); 257 + // Stage 2 fails: boards fetch returns non-ok API response 258 + mockFetch.mockResolvedValueOnce({ ok: false, status: 500, statusText: "Internal Server Error" }); 259 + const routes = await loadHomeRoutes(); 260 + const res = await routes.request("/"); 261 + expect(res.status).toBe(500); 262 + const html = await res.text(); 263 + expect(html).toContain("error-display"); 264 + expect(html).toContain("Something went wrong"); 265 + }); 266 + 267 + it("renders multiple categories with their boards", async () => { 268 + setupSuccessfulFetch({ 269 + categories: [ 270 + { id: "1", name: "Announcements" }, 271 + { id: "2", name: "General" }, 272 + ], 273 + boardsPerCategory: { 274 + "1": [{ id: "10", name: "News" }], 275 + "2": [{ id: "20", name: "Off Topic" }, { id: "21", name: "Introductions" }], 276 + }, 277 + }); 278 + const routes = await loadHomeRoutes(); 279 + const res = await routes.request("/"); 280 + const html = await res.text(); 281 + expect(html).toContain("Announcements"); 282 + expect(html).toContain("General"); 283 + expect(html).toContain("News"); 284 + expect(html).toContain("Off Topic"); 285 + expect(html).toContain("Introductions"); 286 + expect(html).toContain('href="/boards/10"'); 287 + expect(html).toContain('href="/boards/20"'); 288 + }); 289 + });
+27 -2
apps/web/src/routes/__tests__/stubs.test.tsx
··· 39 39 }; 40 40 41 41 it("GET / returns 200 with home title", async () => { 42 + // No session mock needed — getSession short-circuits with no cookie 43 + // GET /api/forum 44 + mockFetch.mockResolvedValueOnce({ 45 + ok: true, 46 + json: () => Promise.resolve({ id: "1", did: "did:plc:forum", name: "Test Forum", description: null, indexedAt: "2025-01-01T00:00:00.000Z" }), 47 + }); 48 + // GET /api/categories (empty — no board fetches needed) 49 + mockFetch.mockResolvedValueOnce({ 50 + ok: true, 51 + json: () => Promise.resolve({ categories: [] }), 52 + }); 42 53 const { createHomeRoutes } = await import("../home.js"); 43 54 const routes = createHomeRoutes("http://localhost:3000"); 44 55 const res = await routes.request("/"); 45 56 expect(res.status).toBe(200); 46 57 const html = await res.text(); 47 - expect(html).toContain("Home — atBB Forum"); 58 + expect(html).toContain("Test Forum — atBB Forum"); 48 59 }); 49 60 50 61 it("GET / shows handle in header when authenticated", async () => { 51 - mockFetch.mockResolvedValueOnce(authenticatedSession); 62 + // Session mock needed — getSession makes a fetch call when cookie is present 63 + mockFetch.mockResolvedValueOnce({ 64 + ok: true, 65 + json: () => Promise.resolve({ authenticated: true, did: "did:plc:abc", handle: "alice.bsky.social" }), 66 + }); 67 + // GET /api/forum 68 + mockFetch.mockResolvedValueOnce({ 69 + ok: true, 70 + json: () => Promise.resolve({ id: "1", did: "did:plc:forum", name: "Test Forum", description: null, indexedAt: "2025-01-01T00:00:00.000Z" }), 71 + }); 72 + // GET /api/categories (empty — no board fetches needed) 73 + mockFetch.mockResolvedValueOnce({ 74 + ok: true, 75 + json: () => Promise.resolve({ categories: [] }), 76 + }); 52 77 const { createHomeRoutes } = await import("../home.js"); 53 78 const routes = createHomeRoutes("http://localhost:3000"); 54 79 const res = await routes.request("/", {
+136 -7
apps/web/src/routes/home.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 { 4 + PageHeader, 5 + EmptyState, 6 + ErrorDisplay, 7 + Card, 8 + } from "../components/index.js"; 9 + import { fetchApi } from "../lib/api.js"; 4 10 import { getSession } from "../lib/session.js"; 11 + import { isProgrammingError, isNetworkError } from "../lib/errors.js"; 12 + 13 + // API response type shapes 14 + interface ForumResponse { 15 + id: string; 16 + did: string; 17 + name: string; 18 + description: string | null; 19 + indexedAt: string; 20 + } 21 + 22 + interface CategoryResponse { 23 + id: string; 24 + did: string; 25 + name: string; 26 + description: string | null; 27 + slug: string | null; 28 + sortOrder: number | null; 29 + } 30 + 31 + interface BoardResponse { 32 + id: string; 33 + did: string; 34 + name: string; 35 + description: string | null; 36 + slug: string | null; 37 + sortOrder: number | null; 38 + } 39 + 40 + interface CategoriesListResponse { 41 + categories: CategoryResponse[]; 42 + } 43 + 44 + interface BoardsListResponse { 45 + boards: BoardResponse[]; 46 + } 47 + 5 48 6 49 export function createHomeRoutes(appviewUrl: string) { 7 50 return new Hono().get("/", async (c) => { 8 51 const auth = await getSession(appviewUrl, c.req.header("cookie")); 52 + 53 + // Stage 1: fetch forum metadata and category list in parallel 54 + let forum: ForumResponse; 55 + let categories: CategoryResponse[]; 56 + try { 57 + const [forumData, categoriesData] = await Promise.all([ 58 + fetchApi<ForumResponse>("/forum"), 59 + fetchApi<CategoriesListResponse>("/categories"), 60 + ]); 61 + forum = forumData; 62 + categories = categoriesData.categories; 63 + } catch (error) { 64 + if (isProgrammingError(error)) throw error; 65 + console.error("Failed to load forum homepage data (stage 1: forum + categories)", { 66 + operation: "GET /", 67 + error: error instanceof Error ? error.message : String(error), 68 + }); 69 + const status = isNetworkError(error) ? 503 : 500; 70 + const message = 71 + status === 503 72 + ? "The forum is temporarily unavailable. Please try again later." 73 + : "Something went wrong loading the forum. Please try again later."; 74 + return c.html( 75 + <BaseLayout title="Error — atBB Forum" auth={auth}> 76 + <ErrorDisplay message={message} /> 77 + </BaseLayout>, 78 + status 79 + ); 80 + } 81 + 82 + // Stage 2: fetch boards for each category in parallel 83 + let boardsByCategory: BoardResponse[][]; 84 + try { 85 + boardsByCategory = await Promise.all( 86 + categories.map((cat) => 87 + fetchApi<BoardsListResponse>(`/categories/${cat.id}/boards`).then( 88 + (data) => data.boards 89 + ) 90 + ) 91 + ); 92 + } catch (error) { 93 + if (isProgrammingError(error)) throw error; 94 + console.error("Failed to load forum homepage data (stage 2: boards)", { 95 + operation: "GET /", 96 + error: error instanceof Error ? error.message : String(error), 97 + }); 98 + const status = isNetworkError(error) ? 503 : 500; 99 + const message = 100 + status === 503 101 + ? "The forum is temporarily unavailable. Please try again later." 102 + : "Something went wrong loading the forum. Please try again later."; 103 + return c.html( 104 + <BaseLayout title="Error — atBB Forum" auth={auth}> 105 + <ErrorDisplay message={message} /> 106 + </BaseLayout>, 107 + status 108 + ); 109 + } 110 + 111 + // Build category+boards pairs for rendering 112 + const categorySections = categories.map((cat, i) => ({ 113 + category: cat, 114 + boards: boardsByCategory[i] ?? [], 115 + })); 116 + 9 117 return c.html( 10 - <BaseLayout title="Home — atBB Forum" auth={auth}> 11 - <PageHeader 12 - title="Welcome to atBB" 13 - description="A BB-style forum on the ATmosphere." 14 - /> 15 - <EmptyState message="No boards yet." /> 118 + <BaseLayout title={`${forum.name} — atBB Forum`} auth={auth}> 119 + <PageHeader title={forum.name} description={forum.description ?? undefined} /> 120 + {categorySections.length === 0 ? ( 121 + <EmptyState message="No categories yet." /> 122 + ) : ( 123 + categorySections.map(({ category, boards }) => ( 124 + <section class="category-section" key={category.id}> 125 + <h2 class="category-header">{category.name}</h2> 126 + <div class="board-grid"> 127 + {boards.length === 0 ? ( 128 + <EmptyState message="No boards in this category yet." /> 129 + ) : ( 130 + boards.map((board) => ( 131 + <a href={`/boards/${board.id}`} class="board-card" key={board.id}> 132 + <Card> 133 + <p class="board-card__name">{board.name}</p> 134 + {board.description && ( 135 + <p class="board-card__description">{board.description}</p> 136 + )} 137 + </Card> 138 + </a> 139 + )) 140 + )} 141 + </div> 142 + </section> 143 + )) 144 + )} 16 145 </BaseLayout> 17 146 ); 18 147 });
+2 -1
docs/atproto-forum-plan.md
··· 217 217 - ATB-22 | `docs/trust-model.md` (new) — covers operator responsibilities, user data guarantees, security implications, and future delegation path; referenced from deployment guide 218 218 219 219 #### Phase 4: Web UI (Week 7–9) 220 - - [ ] Forum homepage: category list, recent topics 220 + - [x] Forum homepage: category list, recent topics 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` 221 222 - [ ] Category view: paginated topic list, sorted by last reply 222 223 - [ ] Topic view: OP + flat replies, pagination 223 224 - [ ] Compose: new topic form, reply form
+82
docs/plans/2026-02-18-homepage-design.md
··· 1 + # Forum Homepage Design 2 + 3 + **Issue:** ATB-27 4 + **Date:** 2026-02-18 5 + **Status:** Approved 6 + 7 + ## Overview 8 + 9 + Build the forum homepage — the main landing page showing forum metadata, categories, and their nested boards. This is the first page with real API integration in the web app. 10 + 11 + ## Data Fetching 12 + 13 + Server-side rendering in two parallel stages: 14 + 15 + 1. **Stage 1 (parallel):** `GET /api/forum` + `GET /api/categories` 16 + 2. **Stage 2 (parallel):** `Promise.all(categories.map(cat => GET /api/categories/:id/boards))` 17 + 18 + Any fetch failure in either stage renders the whole page as an error — a partial homepage (e.g., forum name but no categories) is not useful to the user. 19 + 20 + Topic counts are not included (not available in the API response). Can be added later. 21 + 22 + ## Page Structure 23 + 24 + ``` 25 + BaseLayout (title = forum.name + " — atBB Forum") 26 + PageHeader (title = forum.name, description = forum.description) 27 + [for each category, ordered by sortOrder] 28 + <section class="category-section"> 29 + <h2 class="category-header">Category Name</h2> 30 + <div class="board-grid"> 31 + [for each board] 32 + <a href="/boards/:id"> 33 + <Card> 34 + board.name 35 + board.description (if present) 36 + </Card> 37 + </a> 38 + [if no boards] 39 + EmptyState "No boards in this category yet." 40 + </div> 41 + </section> 42 + [if no categories] 43 + EmptyState "No categories yet." 44 + ``` 45 + 46 + ## CSS 47 + 48 + New classes added to `theme.css`: 49 + 50 + - `.category-section` — vertical spacing between category sections 51 + - `.category-header` — bold `h2` with thick `border-bottom` (neobrutal section divider) 52 + - `.board-grid` — flex-wrap layout for board cards 53 + 54 + Existing `.card` class provides offset shadow. Board cards are `<a>` tags wrapping `<Card>`. 55 + 56 + ## Error Handling 57 + 58 + - Network error (AppView unreachable) → 503 + `ErrorDisplay` 59 + - AppView API error → 500 + `ErrorDisplay` 60 + - Uses `isNetworkError()` pattern from project conventions 61 + 62 + ## Tests 63 + 64 + New test file: `apps/web/src/routes/__tests__/home.test.tsx` 65 + 66 + 1. Renders forum name as page title and `PageHeader` title 67 + 2. Renders forum description in `PageHeader` 68 + 3. Renders category section headers with correct names 69 + 4. Renders board cards with correct names and `/boards/:id` links 70 + 5. Empty state "No categories yet." when no categories 71 + 6. Empty state "No boards in this category yet." when category has no boards 72 + 7. Returns 503 + error display on network error 73 + 8. Returns 500 + error display on AppView API error 74 + 75 + Also update `stubs.test.tsx` to mock forum + category + board fetch calls for the existing `GET /` stub test. 76 + 77 + ## Files Changed 78 + 79 + - `apps/web/src/routes/home.tsx` — full implementation replacing placeholder 80 + - `apps/web/src/routes/__tests__/home.test.tsx` — new dedicated test file 81 + - `apps/web/src/routes/__tests__/stubs.test.tsx` — update `GET /` test with API mocks 82 + - `apps/web/src/index.ts` or `apps/web/public/css/theme.css` — add new CSS classes
+657
docs/plans/2026-02-18-homepage-implementation.md
··· 1 + # Forum Homepage Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Replace the placeholder homepage with a live forum index that fetches and displays forum metadata, categories, and their boards from the AppView API. 6 + 7 + **Architecture:** Server-side rendering in two parallel stages — first fetch forum + categories, then fan out to fetch boards for each category in parallel via `Promise.all`. Any fetch failure renders the full page as an error response. No HTMX partials needed. 8 + 9 + **Tech Stack:** Hono JSX (server-rendered HTML), `fetchApi()` helper, existing `Card`/`EmptyState`/`ErrorDisplay`/`PageHeader` components, CSS custom properties (`var(--token)`) 10 + 11 + --- 12 + 13 + ## Reference 14 + 15 + - Design doc: `docs/plans/2026-02-18-homepage-design.md` 16 + - `fetchApi()` helper: `apps/web/src/lib/api.ts` — throws `"AppView network error: ..."` or `"AppView API error: N ..."` 17 + - Existing components: `apps/web/src/components/index.ts` 18 + - CSS tokens: `apps/web/src/styles/presets/neobrutal-light.ts` 19 + - Theme stylesheet: `apps/web/public/static/css/theme.css` 20 + - Test pattern: see `apps/web/src/routes/__tests__/login.test.tsx` — mock fetch globally, stub `APPVIEW_URL` env var, dynamic-import the route module, test HTML content 21 + 22 + ## API Response Shapes 23 + 24 + ```typescript 25 + // GET /api/forum 26 + { id: string, did: string, name: string, description: string | null, indexedAt: string } 27 + 28 + // GET /api/categories 29 + { categories: Array<{ id: string, did: string, name: string, description: string | null, slug: string | null, sortOrder: number | null, forumId: string, createdAt: string, indexedAt: string }> } 30 + 31 + // GET /api/categories/:id/boards 32 + { boards: Array<{ id: string, did: string, name: string, description: string | null, slug: string | null, sortOrder: number | null, categoryId: string | null, categoryUri: string, createdAt: string, indexedAt: string }> } 33 + ``` 34 + 35 + ## Error Classification Helper 36 + 37 + `fetchApi()` throws with a message prefix. Use this check in the route handler: 38 + 39 + ```typescript 40 + function isNetworkError(error: unknown): boolean { 41 + return error instanceof Error && 42 + error.message.startsWith("AppView network error:"); 43 + } 44 + ``` 45 + 46 + --- 47 + 48 + ## Task 1: Add CSS for homepage layout 49 + 50 + **Files:** 51 + - Modify: `apps/web/public/static/css/theme.css` (append at end) 52 + 53 + **Step 1: Add three new CSS rule blocks** 54 + 55 + Append to the end of `theme.css`: 56 + 57 + ```css 58 + /* ─── Homepage ──────────────────────────────────────────────────────────── */ 59 + 60 + .category-section { 61 + margin-bottom: var(--space-xl); 62 + } 63 + 64 + .category-header { 65 + margin: 0 0 var(--space-md) 0; 66 + font-size: var(--font-size-lg); 67 + padding-bottom: var(--space-sm); 68 + border-bottom: var(--border-width) solid var(--color-border); 69 + } 70 + 71 + .board-grid { 72 + display: flex; 73 + flex-direction: column; 74 + gap: var(--space-md); 75 + } 76 + 77 + .board-card { 78 + display: block; 79 + text-decoration: none; 80 + color: inherit; 81 + } 82 + 83 + .board-card:hover .card { 84 + transform: translate(-2px, -2px); 85 + box-shadow: 8px 8px 0 var(--color-shadow); 86 + } 87 + 88 + .board-card__name { 89 + font-weight: var(--font-weight-bold); 90 + font-size: var(--font-size-base); 91 + margin: 0 0 var(--space-xs) 0; 92 + } 93 + 94 + .board-card__description { 95 + margin: 0; 96 + color: var(--color-text-muted); 97 + font-size: var(--font-size-sm); 98 + } 99 + ``` 100 + 101 + **Step 2: Verify by checking the file ends with those classes** 102 + 103 + No build step needed — CSS is served as a static file. 104 + 105 + **Step 3: Commit** 106 + 107 + ```bash 108 + git add apps/web/public/static/css/theme.css 109 + git commit -m "style: add homepage category and board grid CSS" 110 + ``` 111 + 112 + --- 113 + 114 + ## Task 2: Write failing tests for the homepage 115 + 116 + **Files:** 117 + - Create: `apps/web/src/routes/__tests__/home.test.tsx` 118 + 119 + **Step 1: Create the test file with all failing tests** 120 + 121 + ```typescript 122 + import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 123 + 124 + const mockFetch = vi.fn(); 125 + 126 + describe("createHomeRoutes", () => { 127 + beforeEach(() => { 128 + vi.stubGlobal("fetch", mockFetch); 129 + vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 130 + vi.resetModules(); 131 + }); 132 + 133 + afterEach(() => { 134 + vi.unstubAllGlobals(); 135 + vi.unstubAllEnvs(); 136 + mockFetch.mockReset(); 137 + }); 138 + 139 + // Helper: build a mock fetch response 140 + function mockResponse(body: unknown, ok = true, status = 200) { 141 + return { 142 + ok, 143 + status, 144 + statusText: ok ? "OK" : "Error", 145 + json: () => Promise.resolve(body), 146 + }; 147 + } 148 + 149 + // Sets up the standard 3-call sequence: 150 + // 1. session check (unauthenticated by default) 151 + // 2. GET /api/forum 152 + // 3. GET /api/categories 153 + // 4+. GET /api/categories/:id/boards (one per category) 154 + function setupSuccessfulFetch(options: { 155 + forum?: { name: string; description: string | null }; 156 + categories?: Array<{ id: string; name: string; description?: string | null }>; 157 + boardsPerCategory?: Record<string, Array<{ id: string; name: string; description?: string | null }>>; 158 + } = {}) { 159 + const forum = options.forum ?? { name: "Test Forum", description: "A test forum." }; 160 + const categories = options.categories ?? [ 161 + { id: "1", name: "General", description: "General chat" }, 162 + ]; 163 + const boardsPerCategory = options.boardsPerCategory ?? { 164 + "1": [{ id: "10", name: "Introductions", description: "Say hello!" }], 165 + }; 166 + 167 + // Call 1: session (unauthenticated) 168 + mockFetch.mockResolvedValueOnce({ ok: false, status: 401 }); 169 + // Call 2: GET /api/forum 170 + mockFetch.mockResolvedValueOnce(mockResponse({ 171 + id: "1", 172 + did: "did:plc:forum", 173 + name: forum.name, 174 + description: forum.description, 175 + indexedAt: "2025-01-01T00:00:00.000Z", 176 + })); 177 + // Call 3: GET /api/categories 178 + mockFetch.mockResolvedValueOnce(mockResponse({ 179 + categories: categories.map(c => ({ 180 + id: c.id, 181 + did: "did:plc:forum", 182 + name: c.name, 183 + description: c.description ?? null, 184 + slug: null, 185 + sortOrder: 1, 186 + forumId: "1", 187 + createdAt: "2025-01-01T00:00:00.000Z", 188 + indexedAt: "2025-01-01T00:00:00.000Z", 189 + })), 190 + })); 191 + // Call 4+: boards for each category 192 + for (const cat of categories) { 193 + const boards = boardsPerCategory[cat.id] ?? []; 194 + mockFetch.mockResolvedValueOnce(mockResponse({ 195 + boards: boards.map(b => ({ 196 + id: b.id, 197 + did: "did:plc:forum", 198 + name: b.name, 199 + description: b.description ?? null, 200 + slug: null, 201 + sortOrder: 1, 202 + categoryId: cat.id, 203 + categoryUri: `at://did:plc:forum/space.atbb.forum.category/${cat.id}`, 204 + createdAt: "2025-01-01T00:00:00.000Z", 205 + indexedAt: "2025-01-01T00:00:00.000Z", 206 + })), 207 + })); 208 + } 209 + } 210 + 211 + async function loadHomeRoutes() { 212 + const { createHomeRoutes } = await import("../home.js"); 213 + return createHomeRoutes("http://localhost:3000"); 214 + } 215 + 216 + it("returns 200 with forum name in page title", async () => { 217 + setupSuccessfulFetch({ forum: { name: "My Forum", description: null } }); 218 + const routes = await loadHomeRoutes(); 219 + const res = await routes.request("/"); 220 + expect(res.status).toBe(200); 221 + const html = await res.text(); 222 + expect(html).toContain("My Forum — atBB Forum"); 223 + }); 224 + 225 + it("renders forum name in page header", async () => { 226 + setupSuccessfulFetch({ forum: { name: "My Forum", description: null } }); 227 + const routes = await loadHomeRoutes(); 228 + const res = await routes.request("/"); 229 + const html = await res.text(); 230 + expect(html).toContain("My Forum"); 231 + }); 232 + 233 + it("renders forum description in page header", async () => { 234 + setupSuccessfulFetch({ forum: { name: "My Forum", description: "Welcome to my forum!" } }); 235 + const routes = await loadHomeRoutes(); 236 + const res = await routes.request("/"); 237 + const html = await res.text(); 238 + expect(html).toContain("Welcome to my forum!"); 239 + }); 240 + 241 + it("renders category section header", async () => { 242 + setupSuccessfulFetch({ 243 + categories: [{ id: "1", name: "General Discussion" }], 244 + }); 245 + const routes = await loadHomeRoutes(); 246 + const res = await routes.request("/"); 247 + const html = await res.text(); 248 + expect(html).toContain("General Discussion"); 249 + expect(html).toContain("category-header"); 250 + }); 251 + 252 + it("renders board card with name and link", async () => { 253 + setupSuccessfulFetch({ 254 + categories: [{ id: "1", name: "General" }], 255 + boardsPerCategory: { 256 + "1": [{ id: "42", name: "Introductions" }], 257 + }, 258 + }); 259 + const routes = await loadHomeRoutes(); 260 + const res = await routes.request("/"); 261 + const html = await res.text(); 262 + expect(html).toContain("Introductions"); 263 + expect(html).toContain('href="/boards/42"'); 264 + }); 265 + 266 + it("renders board description when present", async () => { 267 + setupSuccessfulFetch({ 268 + categories: [{ id: "1", name: "General" }], 269 + boardsPerCategory: { 270 + "1": [{ id: "42", name: "Introductions", description: "Say hello to everyone!" }], 271 + }, 272 + }); 273 + const routes = await loadHomeRoutes(); 274 + const res = await routes.request("/"); 275 + const html = await res.text(); 276 + expect(html).toContain("Say hello to everyone!"); 277 + }); 278 + 279 + it("shows empty state when there are no categories", async () => { 280 + setupSuccessfulFetch({ categories: [] }); 281 + const routes = await loadHomeRoutes(); 282 + const res = await routes.request("/"); 283 + const html = await res.text(); 284 + expect(html).toContain("No categories yet"); 285 + }); 286 + 287 + it("shows per-category empty state when category has no boards", async () => { 288 + setupSuccessfulFetch({ 289 + categories: [{ id: "1", name: "Empty Category" }], 290 + boardsPerCategory: { "1": [] }, 291 + }); 292 + const routes = await loadHomeRoutes(); 293 + const res = await routes.request("/"); 294 + const html = await res.text(); 295 + expect(html).toContain("Empty Category"); 296 + expect(html).toContain("No boards in this category yet"); 297 + }); 298 + 299 + it("returns 503 and error display on AppView network error", async () => { 300 + // session check 301 + mockFetch.mockResolvedValueOnce({ ok: false, status: 401 }); 302 + // forum fetch throws network error 303 + mockFetch.mockRejectedValueOnce(new Error("AppView network error: fetch failed")); 304 + const routes = await loadHomeRoutes(); 305 + const res = await routes.request("/"); 306 + expect(res.status).toBe(503); 307 + const html = await res.text(); 308 + expect(html).toContain("error-display"); 309 + expect(html).toContain("unavailable"); 310 + }); 311 + 312 + it("returns 500 and error display on AppView API error", async () => { 313 + // session check 314 + mockFetch.mockResolvedValueOnce({ ok: false, status: 401 }); 315 + // forum fetch returns API error 316 + mockFetch.mockResolvedValueOnce({ ok: false, status: 500, statusText: "Internal Server Error" }); 317 + const routes = await loadHomeRoutes(); 318 + const res = await routes.request("/"); 319 + expect(res.status).toBe(500); 320 + const html = await res.text(); 321 + expect(html).toContain("error-display"); 322 + }); 323 + 324 + it("renders multiple categories with their boards", async () => { 325 + setupSuccessfulFetch({ 326 + categories: [ 327 + { id: "1", name: "Announcements" }, 328 + { id: "2", name: "General" }, 329 + ], 330 + boardsPerCategory: { 331 + "1": [{ id: "10", name: "News" }], 332 + "2": [{ id: "20", name: "Off Topic" }, { id: "21", name: "Introductions" }], 333 + }, 334 + }); 335 + const routes = await loadHomeRoutes(); 336 + const res = await routes.request("/"); 337 + const html = await res.text(); 338 + expect(html).toContain("Announcements"); 339 + expect(html).toContain("General"); 340 + expect(html).toContain("News"); 341 + expect(html).toContain("Off Topic"); 342 + expect(html).toContain("Introductions"); 343 + expect(html).toContain('href="/boards/10"'); 344 + expect(html).toContain('href="/boards/20"'); 345 + }); 346 + }); 347 + ``` 348 + 349 + **Step 2: Run tests to verify they all fail** 350 + 351 + ```bash 352 + cd /path/to/repo 353 + PATH=".devenv/profile/bin:$PATH" pnpm --filter @atbb/web test src/routes/__tests__/home.test.tsx 354 + ``` 355 + 356 + Expected: All tests FAIL with `"createHomeRoutes is not a function"` or similar (route not implemented yet). 357 + 358 + **Step 3: Commit the failing tests** 359 + 360 + ```bash 361 + git add apps/web/src/routes/__tests__/home.test.tsx 362 + git commit -m "test: add failing tests for homepage route (ATB-27)" 363 + ``` 364 + 365 + --- 366 + 367 + ## Task 3: Implement the homepage route 368 + 369 + **Files:** 370 + - Modify: `apps/web/src/routes/home.tsx` 371 + 372 + **Step 1: Replace the placeholder implementation** 373 + 374 + ```typescript 375 + import { Hono } from "hono"; 376 + import { BaseLayout } from "../layouts/base.js"; 377 + import { 378 + PageHeader, 379 + EmptyState, 380 + ErrorDisplay, 381 + Card, 382 + } from "../components/index.js"; 383 + import { getSession } from "../lib/session.js"; 384 + import { fetchApi } from "../lib/api.js"; 385 + 386 + // API response type shapes 387 + interface ForumResponse { 388 + id: string; 389 + did: string; 390 + name: string; 391 + description: string | null; 392 + indexedAt: string; 393 + } 394 + 395 + interface CategoryResponse { 396 + id: string; 397 + did: string; 398 + name: string; 399 + description: string | null; 400 + slug: string | null; 401 + sortOrder: number | null; 402 + } 403 + 404 + interface BoardResponse { 405 + id: string; 406 + did: string; 407 + name: string; 408 + description: string | null; 409 + slug: string | null; 410 + sortOrder: number | null; 411 + } 412 + 413 + interface CategoriesListResponse { 414 + categories: CategoryResponse[]; 415 + } 416 + 417 + interface BoardsListResponse { 418 + boards: BoardResponse[]; 419 + } 420 + 421 + function isNetworkError(error: unknown): boolean { 422 + return ( 423 + error instanceof Error && 424 + error.message.startsWith("AppView network error:") 425 + ); 426 + } 427 + 428 + export function createHomeRoutes(appviewUrl: string) { 429 + return new Hono().get("/", async (c) => { 430 + const auth = await getSession(appviewUrl, c.req.header("cookie")); 431 + 432 + // Stage 1: fetch forum metadata and category list in parallel 433 + let forum: ForumResponse; 434 + let categories: CategoryResponse[]; 435 + try { 436 + const [forumData, categoriesData] = await Promise.all([ 437 + fetchApi<ForumResponse>("/forum"), 438 + fetchApi<CategoriesListResponse>("/categories"), 439 + ]); 440 + forum = forumData; 441 + categories = categoriesData.categories; 442 + } catch (error) { 443 + const status = isNetworkError(error) ? 503 : 500; 444 + const message = 445 + status === 503 446 + ? "The forum is temporarily unavailable. Please try again later." 447 + : "Something went wrong loading the forum. Please try again later."; 448 + return c.html( 449 + <BaseLayout title="Error — atBB Forum" auth={auth}> 450 + <ErrorDisplay message={message} /> 451 + </BaseLayout>, 452 + status 453 + ); 454 + } 455 + 456 + // Stage 2: fetch boards for each category in parallel 457 + let boardsByCategory: BoardResponse[][]; 458 + try { 459 + boardsByCategory = await Promise.all( 460 + categories.map((cat) => 461 + fetchApi<BoardsListResponse>(`/categories/${cat.id}/boards`).then( 462 + (data) => data.boards 463 + ) 464 + ) 465 + ); 466 + } catch (error) { 467 + const status = isNetworkError(error) ? 503 : 500; 468 + const message = 469 + status === 503 470 + ? "The forum is temporarily unavailable. Please try again later." 471 + : "Something went wrong loading the forum. Please try again later."; 472 + return c.html( 473 + <BaseLayout title="Error — atBB Forum" auth={auth}> 474 + <ErrorDisplay message={message} /> 475 + </BaseLayout>, 476 + status 477 + ); 478 + } 479 + 480 + // Build category+boards pairs for rendering 481 + const categorySections = categories.map((cat, i) => ({ 482 + category: cat, 483 + boards: boardsByCategory[i] ?? [], 484 + })); 485 + 486 + return c.html( 487 + <BaseLayout title={`${forum.name} — atBB Forum`} auth={auth}> 488 + <PageHeader title={forum.name} description={forum.description ?? undefined} /> 489 + {categorySections.length === 0 ? ( 490 + <EmptyState message="No categories yet." /> 491 + ) : ( 492 + categorySections.map(({ category, boards }) => ( 493 + <section class="category-section" key={category.id}> 494 + <h2 class="category-header">{category.name}</h2> 495 + <div class="board-grid"> 496 + {boards.length === 0 ? ( 497 + <EmptyState message="No boards in this category yet." /> 498 + ) : ( 499 + boards.map((board) => ( 500 + <a href={`/boards/${board.id}`} class="board-card" key={board.id}> 501 + <Card> 502 + <p class="board-card__name">{board.name}</p> 503 + {board.description && ( 504 + <p class="board-card__description">{board.description}</p> 505 + )} 506 + </Card> 507 + </a> 508 + )) 509 + )} 510 + </div> 511 + </section> 512 + )) 513 + )} 514 + </BaseLayout> 515 + ); 516 + }); 517 + } 518 + ``` 519 + 520 + **Step 2: Run the tests to verify they pass** 521 + 522 + ```bash 523 + PATH=".devenv/profile/bin:$PATH" pnpm --filter @atbb/web test src/routes/__tests__/home.test.tsx 524 + ``` 525 + 526 + Expected: All 11 tests PASS. 527 + 528 + **Step 3: Commit the implementation** 529 + 530 + ```bash 531 + git add apps/web/src/routes/home.tsx 532 + git commit -m "feat: implement forum homepage with live API data (ATB-27)" 533 + ``` 534 + 535 + --- 536 + 537 + ## Task 4: Update the stubs test for the new homepage 538 + 539 + The existing `stubs.test.tsx` has a `GET /` test that will now fail because the homepage makes 3+ API calls but the test only mocks 1 (the session check). 540 + 541 + **Files:** 542 + - Modify: `apps/web/src/routes/__tests__/stubs.test.tsx` 543 + 544 + **Step 1: Run the stubs test first to see the current failure** 545 + 546 + ```bash 547 + PATH=".devenv/profile/bin:$PATH" pnpm --filter @atbb/web test src/routes/__tests__/stubs.test.tsx 548 + ``` 549 + 550 + **Step 2: Update the `GET /` tests in `stubs.test.tsx`** 551 + 552 + Find the two existing `GET /` tests (`"GET / returns 200 with home title"` and `"GET / shows handle in header when authenticated"`) and update them to mock all needed fetch calls. 553 + 554 + Replace both tests with: 555 + 556 + ```typescript 557 + it("GET / returns 200 with home title", async () => { 558 + // session: unauthenticated 559 + mockFetch.mockResolvedValueOnce({ ok: false, status: 401 }); 560 + // GET /api/forum 561 + mockFetch.mockResolvedValueOnce({ 562 + ok: true, 563 + json: () => Promise.resolve({ id: "1", did: "did:plc:forum", name: "Test Forum", description: null, indexedAt: "2025-01-01T00:00:00.000Z" }), 564 + }); 565 + // GET /api/categories 566 + mockFetch.mockResolvedValueOnce({ 567 + ok: true, 568 + json: () => Promise.resolve({ categories: [] }), 569 + }); 570 + const { createHomeRoutes } = await import("../home.js"); 571 + const routes = createHomeRoutes("http://localhost:3000"); 572 + const res = await routes.request("/"); 573 + expect(res.status).toBe(200); 574 + const html = await res.text(); 575 + expect(html).toContain("Test Forum — atBB Forum"); 576 + }); 577 + 578 + it("GET / shows handle in header when authenticated", async () => { 579 + // session: authenticated 580 + mockFetch.mockResolvedValueOnce({ 581 + ok: true, 582 + json: () => Promise.resolve({ authenticated: true, did: "did:plc:abc", handle: "alice.bsky.social" }), 583 + }); 584 + // GET /api/forum 585 + mockFetch.mockResolvedValueOnce({ 586 + ok: true, 587 + json: () => Promise.resolve({ id: "1", did: "did:plc:forum", name: "Test Forum", description: null, indexedAt: "2025-01-01T00:00:00.000Z" }), 588 + }); 589 + // GET /api/categories 590 + mockFetch.mockResolvedValueOnce({ 591 + ok: true, 592 + json: () => Promise.resolve({ categories: [] }), 593 + }); 594 + const { createHomeRoutes } = await import("../home.js"); 595 + const routes = createHomeRoutes("http://localhost:3000"); 596 + const res = await routes.request("/", { 597 + headers: { cookie: "atbb_session=token" }, 598 + }); 599 + const html = await res.text(); 600 + expect(html).toContain("alice.bsky.social"); 601 + expect(html).toContain("Log out"); 602 + expect(html).not.toContain('href="/login"'); 603 + }); 604 + ``` 605 + 606 + **Step 3: Run all web tests to verify everything passes** 607 + 608 + ```bash 609 + PATH=".devenv/profile/bin:$PATH" pnpm --filter @atbb/web test 610 + ``` 611 + 612 + Expected: All tests PASS. 613 + 614 + **Step 4: Commit** 615 + 616 + ```bash 617 + git add apps/web/src/routes/__tests__/stubs.test.tsx 618 + git commit -m "test: update stubs test to mock homepage API calls" 619 + ``` 620 + 621 + --- 622 + 623 + ## Task 5: Run full test suite and verify 624 + 625 + **Step 1: Run the full test suite** 626 + 627 + ```bash 628 + PATH=".devenv/profile/bin:$PATH" pnpm test 629 + ``` 630 + 631 + Expected: All tests PASS across all packages. 632 + 633 + **Step 2: Do a type check** 634 + 635 + ```bash 636 + PATH=".devenv/profile/bin:$PATH" pnpm turbo lint 637 + ``` 638 + 639 + Expected: No type errors. 640 + 641 + **Step 3: If all green, update Linear** 642 + 643 + Mark ATB-27 as Done in Linear. Add a comment noting: 644 + - Homepage now fetches live data from `/api/forum`, `/api/categories`, and `/api/categories/:id/boards` 645 + - Server-side rendered, no HTMX partials needed for MVP 646 + - Topic counts deferred (not available in API response) 647 + 648 + --- 649 + 650 + ## Done 651 + 652 + The homepage now: 653 + - Fetches live forum name and description from AppView 654 + - Displays all categories as section headers 655 + - Nests boards under each category as clickable cards linking to `/boards/:id` 656 + - Shows empty states for no categories and no boards per category 657 + - Returns 503 on network errors, 500 on API errors