Forum Homepage Implementation Plan#
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Replace the placeholder homepage with a live forum index that fetches and displays forum metadata, categories, and their boards from the AppView API.
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.
Tech Stack: Hono JSX (server-rendered HTML), fetchApi() helper, existing Card/EmptyState/ErrorDisplay/PageHeader components, CSS custom properties (var(--token))
Reference#
- Design doc:
docs/plans/2026-02-18-homepage-design.md fetchApi()helper:apps/web/src/lib/api.ts— throws"AppView network error: ..."or"AppView API error: N ..."- Existing components:
apps/web/src/components/index.ts - CSS tokens:
apps/web/src/styles/presets/neobrutal-light.ts - Theme stylesheet:
apps/web/public/static/css/theme.css - Test pattern: see
apps/web/src/routes/__tests__/login.test.tsx— mock fetch globally, stubAPPVIEW_URLenv var, dynamic-import the route module, test HTML content
API Response Shapes#
// GET /api/forum
{ id: string, did: string, name: string, description: string | null, indexedAt: string }
// GET /api/categories
{ categories: Array<{ id: string, did: string, name: string, description: string | null, slug: string | null, sortOrder: number | null, forumId: string, createdAt: string, indexedAt: string }> }
// GET /api/categories/:id/boards
{ 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 }> }
Error Classification Helper#
fetchApi() throws with a message prefix. Use this check in the route handler:
function isNetworkError(error: unknown): boolean {
return error instanceof Error &&
error.message.startsWith("AppView network error:");
}
Task 1: Add CSS for homepage layout#
Files:
- Modify:
apps/web/public/static/css/theme.css(append at end)
Step 1: Add three new CSS rule blocks
Append to the end of theme.css:
/* ─── Homepage ──────────────────────────────────────────────────────────── */
.category-section {
margin-bottom: var(--space-xl);
}
.category-header {
margin: 0 0 var(--space-md) 0;
font-size: var(--font-size-lg);
padding-bottom: var(--space-sm);
border-bottom: var(--border-width) solid var(--color-border);
}
.board-grid {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.board-card {
display: block;
text-decoration: none;
color: inherit;
}
.board-card:hover .card {
transform: translate(-2px, -2px);
box-shadow: 8px 8px 0 var(--color-shadow);
}
.board-card__name {
font-weight: var(--font-weight-bold);
font-size: var(--font-size-base);
margin: 0 0 var(--space-xs) 0;
}
.board-card__description {
margin: 0;
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
Step 2: Verify by checking the file ends with those classes
No build step needed — CSS is served as a static file.
Step 3: Commit
git add apps/web/public/static/css/theme.css
git commit -m "style: add homepage category and board grid CSS"
Task 2: Write failing tests for the homepage#
Files:
- Create:
apps/web/src/routes/__tests__/home.test.tsx
Step 1: Create the test file with all failing tests
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const mockFetch = vi.fn();
describe("createHomeRoutes", () => {
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch);
vi.stubEnv("APPVIEW_URL", "http://localhost:3000");
vi.resetModules();
});
afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
mockFetch.mockReset();
});
// Helper: build a mock fetch response
function mockResponse(body: unknown, ok = true, status = 200) {
return {
ok,
status,
statusText: ok ? "OK" : "Error",
json: () => Promise.resolve(body),
};
}
// Sets up the standard 3-call sequence:
// 1. session check (unauthenticated by default)
// 2. GET /api/forum
// 3. GET /api/categories
// 4+. GET /api/categories/:id/boards (one per category)
function setupSuccessfulFetch(options: {
forum?: { name: string; description: string | null };
categories?: Array<{ id: string; name: string; description?: string | null }>;
boardsPerCategory?: Record<string, Array<{ id: string; name: string; description?: string | null }>>;
} = {}) {
const forum = options.forum ?? { name: "Test Forum", description: "A test forum." };
const categories = options.categories ?? [
{ id: "1", name: "General", description: "General chat" },
];
const boardsPerCategory = options.boardsPerCategory ?? {
"1": [{ id: "10", name: "Introductions", description: "Say hello!" }],
};
// Call 1: session (unauthenticated)
mockFetch.mockResolvedValueOnce({ ok: false, status: 401 });
// Call 2: GET /api/forum
mockFetch.mockResolvedValueOnce(mockResponse({
id: "1",
did: "did:plc:forum",
name: forum.name,
description: forum.description,
indexedAt: "2025-01-01T00:00:00.000Z",
}));
// Call 3: GET /api/categories
mockFetch.mockResolvedValueOnce(mockResponse({
categories: categories.map(c => ({
id: c.id,
did: "did:plc:forum",
name: c.name,
description: c.description ?? null,
slug: null,
sortOrder: 1,
forumId: "1",
createdAt: "2025-01-01T00:00:00.000Z",
indexedAt: "2025-01-01T00:00:00.000Z",
})),
}));
// Call 4+: boards for each category
for (const cat of categories) {
const boards = boardsPerCategory[cat.id] ?? [];
mockFetch.mockResolvedValueOnce(mockResponse({
boards: boards.map(b => ({
id: b.id,
did: "did:plc:forum",
name: b.name,
description: b.description ?? null,
slug: null,
sortOrder: 1,
categoryId: cat.id,
categoryUri: `at://did:plc:forum/space.atbb.forum.category/${cat.id}`,
createdAt: "2025-01-01T00:00:00.000Z",
indexedAt: "2025-01-01T00:00:00.000Z",
})),
}));
}
}
async function loadHomeRoutes() {
const { createHomeRoutes } = await import("../home.js");
return createHomeRoutes("http://localhost:3000");
}
it("returns 200 with forum name in page title", async () => {
setupSuccessfulFetch({ forum: { name: "My Forum", description: null } });
const routes = await loadHomeRoutes();
const res = await routes.request("/");
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("My Forum — atBB Forum");
});
it("renders forum name in page header", async () => {
setupSuccessfulFetch({ forum: { name: "My Forum", description: null } });
const routes = await loadHomeRoutes();
const res = await routes.request("/");
const html = await res.text();
expect(html).toContain("My Forum");
});
it("renders forum description in page header", async () => {
setupSuccessfulFetch({ forum: { name: "My Forum", description: "Welcome to my forum!" } });
const routes = await loadHomeRoutes();
const res = await routes.request("/");
const html = await res.text();
expect(html).toContain("Welcome to my forum!");
});
it("renders category section header", async () => {
setupSuccessfulFetch({
categories: [{ id: "1", name: "General Discussion" }],
});
const routes = await loadHomeRoutes();
const res = await routes.request("/");
const html = await res.text();
expect(html).toContain("General Discussion");
expect(html).toContain("category-header");
});
it("renders board card with name and link", async () => {
setupSuccessfulFetch({
categories: [{ id: "1", name: "General" }],
boardsPerCategory: {
"1": [{ id: "42", name: "Introductions" }],
},
});
const routes = await loadHomeRoutes();
const res = await routes.request("/");
const html = await res.text();
expect(html).toContain("Introductions");
expect(html).toContain('href="/boards/42"');
});
it("renders board description when present", async () => {
setupSuccessfulFetch({
categories: [{ id: "1", name: "General" }],
boardsPerCategory: {
"1": [{ id: "42", name: "Introductions", description: "Say hello to everyone!" }],
},
});
const routes = await loadHomeRoutes();
const res = await routes.request("/");
const html = await res.text();
expect(html).toContain("Say hello to everyone!");
});
it("shows empty state when there are no categories", async () => {
setupSuccessfulFetch({ categories: [] });
const routes = await loadHomeRoutes();
const res = await routes.request("/");
const html = await res.text();
expect(html).toContain("No categories yet");
});
it("shows per-category empty state when category has no boards", async () => {
setupSuccessfulFetch({
categories: [{ id: "1", name: "Empty Category" }],
boardsPerCategory: { "1": [] },
});
const routes = await loadHomeRoutes();
const res = await routes.request("/");
const html = await res.text();
expect(html).toContain("Empty Category");
expect(html).toContain("No boards in this category yet");
});
it("returns 503 and error display on AppView network error", async () => {
// session check
mockFetch.mockResolvedValueOnce({ ok: false, status: 401 });
// forum fetch throws network error
mockFetch.mockRejectedValueOnce(new Error("AppView network error: fetch failed"));
const routes = await loadHomeRoutes();
const res = await routes.request("/");
expect(res.status).toBe(503);
const html = await res.text();
expect(html).toContain("error-display");
expect(html).toContain("unavailable");
});
it("returns 500 and error display on AppView API error", async () => {
// session check
mockFetch.mockResolvedValueOnce({ ok: false, status: 401 });
// forum fetch returns API error
mockFetch.mockResolvedValueOnce({ ok: false, status: 500, statusText: "Internal Server Error" });
const routes = await loadHomeRoutes();
const res = await routes.request("/");
expect(res.status).toBe(500);
const html = await res.text();
expect(html).toContain("error-display");
});
it("renders multiple categories with their boards", async () => {
setupSuccessfulFetch({
categories: [
{ id: "1", name: "Announcements" },
{ id: "2", name: "General" },
],
boardsPerCategory: {
"1": [{ id: "10", name: "News" }],
"2": [{ id: "20", name: "Off Topic" }, { id: "21", name: "Introductions" }],
},
});
const routes = await loadHomeRoutes();
const res = await routes.request("/");
const html = await res.text();
expect(html).toContain("Announcements");
expect(html).toContain("General");
expect(html).toContain("News");
expect(html).toContain("Off Topic");
expect(html).toContain("Introductions");
expect(html).toContain('href="/boards/10"');
expect(html).toContain('href="/boards/20"');
});
});
Step 2: Run tests to verify they all fail
cd /path/to/repo
PATH=".devenv/profile/bin:$PATH" pnpm --filter @atbb/web test src/routes/__tests__/home.test.tsx
Expected: All tests FAIL with "createHomeRoutes is not a function" or similar (route not implemented yet).
Step 3: Commit the failing tests
git add apps/web/src/routes/__tests__/home.test.tsx
git commit -m "test: add failing tests for homepage route (ATB-27)"
Task 3: Implement the homepage route#
Files:
- Modify:
apps/web/src/routes/home.tsx
Step 1: Replace the placeholder implementation
import { Hono } from "hono";
import { BaseLayout } from "../layouts/base.js";
import {
PageHeader,
EmptyState,
ErrorDisplay,
Card,
} from "../components/index.js";
import { getSession } from "../lib/session.js";
import { fetchApi } from "../lib/api.js";
// API response type shapes
interface ForumResponse {
id: string;
did: string;
name: string;
description: string | null;
indexedAt: string;
}
interface CategoryResponse {
id: string;
did: string;
name: string;
description: string | null;
slug: string | null;
sortOrder: number | null;
}
interface BoardResponse {
id: string;
did: string;
name: string;
description: string | null;
slug: string | null;
sortOrder: number | null;
}
interface CategoriesListResponse {
categories: CategoryResponse[];
}
interface BoardsListResponse {
boards: BoardResponse[];
}
function isNetworkError(error: unknown): boolean {
return (
error instanceof Error &&
error.message.startsWith("AppView network error:")
);
}
export function createHomeRoutes(appviewUrl: string) {
return new Hono().get("/", async (c) => {
const auth = await getSession(appviewUrl, c.req.header("cookie"));
// Stage 1: fetch forum metadata and category list in parallel
let forum: ForumResponse;
let categories: CategoryResponse[];
try {
const [forumData, categoriesData] = await Promise.all([
fetchApi<ForumResponse>("/forum"),
fetchApi<CategoriesListResponse>("/categories"),
]);
forum = forumData;
categories = categoriesData.categories;
} catch (error) {
const status = isNetworkError(error) ? 503 : 500;
const message =
status === 503
? "The forum is temporarily unavailable. Please try again later."
: "Something went wrong loading the forum. Please try again later.";
return c.html(
<BaseLayout title="Error — atBB Forum" auth={auth}>
<ErrorDisplay message={message} />
</BaseLayout>,
status
);
}
// Stage 2: fetch boards for each category in parallel
let boardsByCategory: BoardResponse[][];
try {
boardsByCategory = await Promise.all(
categories.map((cat) =>
fetchApi<BoardsListResponse>(`/categories/${cat.id}/boards`).then(
(data) => data.boards
)
)
);
} catch (error) {
const status = isNetworkError(error) ? 503 : 500;
const message =
status === 503
? "The forum is temporarily unavailable. Please try again later."
: "Something went wrong loading the forum. Please try again later.";
return c.html(
<BaseLayout title="Error — atBB Forum" auth={auth}>
<ErrorDisplay message={message} />
</BaseLayout>,
status
);
}
// Build category+boards pairs for rendering
const categorySections = categories.map((cat, i) => ({
category: cat,
boards: boardsByCategory[i] ?? [],
}));
return c.html(
<BaseLayout title={`${forum.name} — atBB Forum`} auth={auth}>
<PageHeader title={forum.name} description={forum.description ?? undefined} />
{categorySections.length === 0 ? (
<EmptyState message="No categories yet." />
) : (
categorySections.map(({ category, boards }) => (
<section class="category-section" key={category.id}>
<h2 class="category-header">{category.name}</h2>
<div class="board-grid">
{boards.length === 0 ? (
<EmptyState message="No boards in this category yet." />
) : (
boards.map((board) => (
<a href={`/boards/${board.id}`} class="board-card" key={board.id}>
<Card>
<p class="board-card__name">{board.name}</p>
{board.description && (
<p class="board-card__description">{board.description}</p>
)}
</Card>
</a>
))
)}
</div>
</section>
))
)}
</BaseLayout>
);
});
}
Step 2: Run the tests to verify they pass
PATH=".devenv/profile/bin:$PATH" pnpm --filter @atbb/web test src/routes/__tests__/home.test.tsx
Expected: All 11 tests PASS.
Step 3: Commit the implementation
git add apps/web/src/routes/home.tsx
git commit -m "feat: implement forum homepage with live API data (ATB-27)"
Task 4: Update the stubs test for the new homepage#
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).
Files:
- Modify:
apps/web/src/routes/__tests__/stubs.test.tsx
Step 1: Run the stubs test first to see the current failure
PATH=".devenv/profile/bin:$PATH" pnpm --filter @atbb/web test src/routes/__tests__/stubs.test.tsx
Step 2: Update the GET / tests in stubs.test.tsx
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.
Replace both tests with:
it("GET / returns 200 with home title", async () => {
// session: unauthenticated
mockFetch.mockResolvedValueOnce({ ok: false, status: 401 });
// GET /api/forum
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ id: "1", did: "did:plc:forum", name: "Test Forum", description: null, indexedAt: "2025-01-01T00:00:00.000Z" }),
});
// GET /api/categories
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ categories: [] }),
});
const { createHomeRoutes } = await import("../home.js");
const routes = createHomeRoutes("http://localhost:3000");
const res = await routes.request("/");
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("Test Forum — atBB Forum");
});
it("GET / shows handle in header when authenticated", async () => {
// session: authenticated
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ authenticated: true, did: "did:plc:abc", handle: "alice.bsky.social" }),
});
// GET /api/forum
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ id: "1", did: "did:plc:forum", name: "Test Forum", description: null, indexedAt: "2025-01-01T00:00:00.000Z" }),
});
// GET /api/categories
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ categories: [] }),
});
const { createHomeRoutes } = await import("../home.js");
const routes = createHomeRoutes("http://localhost:3000");
const res = await routes.request("/", {
headers: { cookie: "atbb_session=token" },
});
const html = await res.text();
expect(html).toContain("alice.bsky.social");
expect(html).toContain("Log out");
expect(html).not.toContain('href="/login"');
});
Step 3: Run all web tests to verify everything passes
PATH=".devenv/profile/bin:$PATH" pnpm --filter @atbb/web test
Expected: All tests PASS.
Step 4: Commit
git add apps/web/src/routes/__tests__/stubs.test.tsx
git commit -m "test: update stubs test to mock homepage API calls"
Task 5: Run full test suite and verify#
Step 1: Run the full test suite
PATH=".devenv/profile/bin:$PATH" pnpm test
Expected: All tests PASS across all packages.
Step 2: Do a type check
PATH=".devenv/profile/bin:$PATH" pnpm turbo lint
Expected: No type errors.
Step 3: If all green, update Linear
Mark ATB-27 as Done in Linear. Add a comment noting:
- Homepage now fetches live data from
/api/forum,/api/categories, and/api/categories/:id/boards - Server-side rendered, no HTMX partials needed for MVP
- Topic counts deferred (not available in API response)
Done#
The homepage now:
- Fetches live forum name and description from AppView
- Displays all categories as section headers
- Nests boards under each category as clickable cards linking to
/boards/:id - Shows empty states for no categories and no boards per category
- Returns 503 on network errors, 500 on API errors