Architect a Tested Activity Feed

Build a tested Next.js activity feed with typed data and accessible Kudos.

Introduction

30 Second Summary

Running feeds make a workout look effortless once it reaches the screen. The code underneath determines whether the next feature fits cleanly or forces a rewrite.

In this project, you will build a tested responsive activity feed with Claude Code, Next.js, TypeScript, and Tailwind CSS. The repository pattern preserves stable activity identity behind a replaceable data boundary.

What You'll Build

Open Paceboard to see a responsive running feed where every card presents a route preview with formatted metrics beside a reversible Kudos control.

By the end of this project, you'll have:

  • A responsive activity feed where two realistic running cards adapt to desktop or narrow screens while preserving readable metrics and accessible route previews.
  • A replaceable data boundary where ActivityRepository supplies stable activity identity without exposing fixture storage to the interface.
  • An executable proof package where Vitest verifies the feed's interaction contracts. A production build proves the application compiles. A Mermaid architecture diagram records the future integration boundaries.
  • Secret Mission: Strengthen the reusable /feed-review Claude Code skill so it reports PASS or FAIL across the repository's acceptance criteria.

Are there any prerequisites?

You need existing Claude Code access. You also need Node.js 22.12.0 or newer.

Before We Start

This project starts as an evolutionary local-first slice. Before any hands-on work, commit to an architecture that can grow without pretending persistence or identity already exists.

A typed ActivityRepository isolates today's seeded data behind a replaceable boundary. Future authenticated PostgreSQL adapters can implement that boundary without changing the UI.

Establish the Typed Next.js Toolchain

Tests only earn trust when every contributor runs the same Node.js runtime. Unpinned dependencies can turn the same change into different results.

This step establishes a pinned Next.js application with strict TypeScript. You will also configure Tailwind CSS and Vitest before feature work begins.

In this step, get ready to:
  • Verify Node.js 22.12.0 or newer.
  • Create the pinned Paceboard toolchain.
  • Load the repository instructions in Claude Code.
Verify the local toolchain

Vitest 5.0.5 requires Node.js 22.12.0 or newer. Checking the runtime first prevents dependency failures from hiding the real cause.

  • Press Cmd+Space on macOS or the Windows key on Windows to open system search.
  • Type Terminal on macOS or PowerShell on Windows.
  • Press Enter to open the terminal application.
  • Check the installed Node.js runtime, npm client, and Claude Code client by running:
node --version
npm --version
claude --version

What Do These Checks Prove?

  • The first command confirms that your runtime satisfies the minimum required by Vitest 5.0.5.
  • The second command confirms that npm is available to install the pinned dependency graph.
  • The third command confirms that the claude client can start a repository session.

✔️ I See Node.js 22.12.0 or Higher

Your runtime meets the project requirement. The local toolchain can support the pinned test stack.

ⓧ I See an Older Version

The installed runtime is below the minimum required by Vitest. Upgrade to the current Node.js 24 LTS release before creating the repository.

  • Visit the official Node.js download page.
  • Download the current Node.js 24 LTS installer for your operating system.
  • Complete the installer.
  • Close the existing terminal application.
  • Reopen the terminal application through system search.
  • Repeat the version checks by running:
node --version
npm --version
claude --version

What Should You Confirm?

The Node.js result must show 22.12.0 or newer. The other two commands should each print an installed version.

ⓧ Node.js Is Not Found

Your terminal cannot currently reach a Node.js installation. Installing the current LTS release also provides npm.

  • Visit the official Node.js download page.
  • Download the current Node.js 24 LTS installer for your operating system.
  • Complete the installer.
  • Close the existing terminal application.
  • Reopen the terminal application through system search.
  • Repeat the version checks by running:
node --version
npm --version
claude --version

What Should You Confirm?

You should see a Node.js version of 22.12.0 or newer. You should also see installed versions for npm and Claude Code.

Still Missing a Tool?

If npm remains unavailable after the Node.js installation, close every terminal window before repeating the checks. A new terminal session reloads the executable search path.

If Claude Code remains unavailable, confirm that your existing Claude Code access is active. Then follow its official installation instructions before repeating the check.

Ask for help with the tool that is still unavailable.

Create the pinned Paceboard repository

A package manifest turns version choices into a reproducible contract. The surrounding configuration gives Next.js, Tailwind CSS, TypeScript, and Vitest the same project boundaries.

  • Create the paceboard folder on your Desktop by running these commands:
cd ~/Desktop
mkdir paceboard
cd paceboard

What Do These Commands Do?

  • The first command moves the terminal to your Desktop.
  • The second command creates the paceboard repository folder.
  • The third command makes paceboard the current terminal location.
  • Confirm that your terminal prompt now ends inside the paceboard folder.
  • Press Cmd+Space on macOS or the Windows key on Windows to open system search.
  • Type VS Code.
  • Press Enter to open VS Code.
  • Use the file tree in VS Code to open the paceboard folder from your Desktop.
  • Approve the workspace trust prompt if VS Code asks for confirmation.

Cannot Find the Repository Folder?

Check your Desktop for a folder named paceboard. If it is missing, return to the terminal and repeat the folder creation commands.

Help me locate the Paceboard repository folder.

The package.json file separates runtime dependencies from development-only verification tools. You will paste it in two connected chunks so that each section remains readable.

  • Create package.json inside the paceboard folder using the VS Code file tree.
  • Paste this opening portion into package.json without saving the incomplete file:
{
  "name": "paceboard",
  "version": "0.1.0",
  "private": true,
  "engines": {
    "node": ">=22.12.0"
  },
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "test": "vitest run"
  },
  "dependencies": {
    "next": "16.4.3",
    "react": "19.3.1",
    "react-dom": "19.3.1"
  },

What Does This Section Define?

This section pins the Node.js engine requirement. It also defines the development, build, production, and test commands.

Next.js and React remain runtime dependencies because the finished application needs them to render.

  • Append the remaining development dependencies directly below the final comma:
  "devDependencies": {
    "@tailwindcss/postcss": "4.3.3",
    "@testing-library/dom": "10.4.2",
    "@testing-library/react": "16.3.3",
    "@types/node": "26.6.5",
    "@types/react": "19.3.0",
    "@types/react-dom": "19.3.0",
    "@vitejs/plugin-react": "6.1.2",
    "jsdom": "30.1.3",
    "postcss": "8.5.31",
    "tailwindcss": "4.3.3",
    "typescript": "7.0.2",
    "vite": "8.3.5",
    "vite-tsconfig-paths": "6.1.1",
    "vitest": "5.0.5"
  }
}

Why Pin Every Development Dependency?

Exact versions keep the TypeScript compiler, test runner, browser simulation, and CSS pipeline reproducible. React Testing Library drives component checks through user-facing behavior.

jsdom supplies the browser-like environment that those tests need inside Node.js.

  • Save package.json.
  • Install the pinned dependency graph by running:
npm install

What Does npm Install Create?

npm reads the pinned package manifest and installs the dependency graph into node_modules. It also records the resolved graph in package-lock.json.

The first installation can take a few minutes while npm downloads the packages. A stream of package activity means the install is still progressing.

  • Wait for the terminal prompt to return.
  • Confirm that node_modules appears in the VS Code file tree.
  • Confirm that package-lock.json appears beside package.json.

Did the Dependency Install Fail?

Check that package.json has one opening brace and one closing brace. Confirm that the dependencies section ends with a comma before devDependencies.

If the failure mentions runtime compatibility, repeat the Node.js version check. The result must be 22.12.0 or newer.

Help me diagnose my npm installation failure.

Strict compiler settings make the future activity contract enforceable. Path mapping also keeps imports readable as the repository gains data, library, and component folders.

  • Create tsconfig.json inside the paceboard folder using the VS Code file tree.
  • Paste this configuration into tsconfig.json:
{
  "compilerOptions": {
    "target": "ES2017",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "react-jsx",
    "incremental": true,
    "plugins": [{ "name": "next" }],
    "paths": { "@/*": ["./*"] }
  },
  "include": [
    "next-env.d.ts",
    "**/*.ts",
    "**/*.tsx",
    ".next/types/**/*.ts",
    ".next/dev/types/**/*.ts",
    "**/*.mts"
  ],
  "exclude": ["node_modules"]
}

What Does This Configuration Enforce?

  • The strict setting enables TypeScript's strict type checks.
  • The noEmit setting makes TypeScript validate source without producing duplicate JavaScript files.
  • The @/* mapping lets application code import from the named repository boundary.
  • Save tsconfig.json.
  • Confirm that tsconfig.json appears beside package.json in the VS Code file tree.

Seeing JSON Warnings?

Compare every brace and comma with the reference. The include array belongs outside compilerOptions.

Help me find the invalid JSON in tsconfig.json.

Tailwind CSS 4.3.3 enters the build through PostCSS. The global stylesheet then imports Tailwind and establishes a predictable page canvas.

  • Create postcss.config.mjs inside the paceboard folder using the VS Code file tree.
  • Paste this configuration into postcss.config.mjs:
const config = {
  plugins: {
    "@tailwindcss/postcss": {},
  },
};

export default config;

What Does This Configuration Do?

PostCSS loads the Tailwind plugin while Next.js processes global styles. The exported configuration gives the build tool one shared plugin definition.

  • Save postcss.config.mjs.
  • Confirm that postcss.config.mjs appears in the VS Code file tree.
  • Create an app folder inside paceboard using the VS Code file tree.
  • Create globals.css inside the app folder.
  • Paste this stylesheet into app/globals.css:
@import "tailwindcss";

body {
  margin: 0;
  min-height: 100vh;
}

What Does the Global Stylesheet Do?

The import makes Tailwind utility classes available throughout the application. The body rules remove the browser margin and keep the page at least as tall as the viewport.

  • Save app/globals.css.
  • Confirm that globals.css appears inside the app folder.
  • Create layout.tsx inside the app folder.
  • Paste this layout into app/layout.tsx:
import "./globals.css";

export const metadata = {
  title: "Paceboard",
  description: "A typed and tested social running feed",
};

export default function RootLayout({
  children,
}: Readonly<{ children: React.ReactNode }>) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}

What Does the Root Layout Provide?

The layout imports the global stylesheet once for every route. It also supplies Paceboard's page metadata and the shared HTML shell.

  • Save app/layout.tsx.
  • Confirm that layout.tsx appears inside the app folder.

Tailwind Files in the Wrong Location?

Keep postcss.config.mjs beside package.json. Keep globals.css and layout.tsx inside the app folder.

Help me correct my Paceboard Tailwind file locations.

The test configuration creates a browser-like environment for synchronous React components. Automatic cleanup keeps one test's rendered elements out of the next test.

  • Create vitest.config.mts inside the paceboard folder using the VS Code file tree.
  • Paste this configuration into vitest.config.mts:
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
import tsconfigPaths from "vite-tsconfig-paths";

export default defineConfig({
  plugins: [tsconfigPaths(), react()],
  test: {
    environment: "jsdom",
    setupFiles: ["./vitest.setup.ts"],
  },
});

What Does the Test Configuration Provide?

  • The React plugin transforms component syntax for tests.
  • The path plugin resolves the same @/* imports used by the application.
  • The jsdom environment supplies browser APIs to component tests.
  • Save vitest.config.mts.
  • Confirm that vitest.config.mts appears in the VS Code file tree.
  • Create vitest.setup.ts inside the paceboard folder.
  • Paste this setup hook into vitest.setup.ts:
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";

afterEach(() => {
  cleanup();
});

Why Clean Up After Each Test?

React Testing Library adds rendered elements to the test document. The cleanup hook removes them after every test so later assertions start from an empty document.

  • Save vitest.setup.ts.
  • Confirm that vitest.setup.ts appears in the VS Code file tree.

Test Imports Showing as Unresolved?

Confirm that the npm installation completed before diagnosing editor warnings. Then compare the package names in package.json with the imports in both Vitest files.

Help me diagnose unresolved Vitest imports.

Repository-level instructions give every Claude Code session the same scope and quality gates. They also protect this first slice from premature infrastructure work.

  • Create CLAUDE.md inside the paceboard folder using the VS Code file tree.
  • Paste these repository instructions into CLAUDE.md:
# Product

- Paceboard is Project 1 in a progressive Strava-inspired application series.
- This slice covers a typed, tested activity feed with local seeded data.
- PostgreSQL persistence, authentication, GPS ingestion, and real maps are explicitly out of scope.

# Architecture

- `app/page.tsx` is the server composition boundary.
- UI code depends on `Activity`, not on fixture internals.
- Server code depends on `ActivityRepository`, not directly on seeded data.
- `ActivityId` is opaque to UI code. Adapters must return stable, unique values across reads.
- `ActivityFeed` owns transient client-only Kudos state keyed by `ActivityId`.
- Preserve `listForViewer(viewerId: string | null)` so Project 2 can supply authenticated identity.

# Commands

- Development: `npm run dev`
- Tests: `npm test`
- Production verification: `npm run build`

# Quality gates

- Keep TypeScript strict and do not introduce `any`.
- Keep source metrics numeric and format them at the presentation boundary.
- Keep activity IDs stable and unique across repository reads.
- Keep Kudos state independent per activity and derive counts from source data plus toggle state.
- Prefer role and accessible-name queries in component tests.
- Run tests and the production build after implementation changes.
- Do not add a database, authentication library, map SDK, or external API in this project.

Why Define the Boundaries Now?

The instructions reserve clear seams for authentication and PostgreSQL without adding either system to this slice. Claude Code can now evaluate suggestions against the same architecture rules throughout the project.

  • Save CLAUDE.md.
  • Confirm that CLAUDE.md appears beside package.json.

The temporary page provides a visible toolchain check before the activity contract exists. It remains a server page because this step introduces no browser interaction.

  • Create page.tsx inside the app folder using the VS Code file tree.
  • Add a default Home function to app/page.tsx.
  • Return a main element from the function.
  • Set the main element's classes to min-h-screen bg-slate-100 px-4 py-8 text-slate-950 sm:px-6.
  • Add an h1 element containing Paceboard.
  • Set the heading's classes to text-4xl font-black tracking-tight.
  • Save app/page.tsx.
  • Confirm that page.tsx appears beside the other files in app.

Temporary Page Showing Editor Errors?

Confirm that Home is exported as the default function. Check that every opened JSX element has a matching closing element.

Help me correct the temporary Paceboard page.

✔️ Awesome, I Have Everything

Your repository now contains the pinned manifest, strict compiler settings, Tailwind pipeline, Vitest setup, repository instructions, and temporary page. Save every open file before continuing.

ⓧ I Want to Double Check the Full Code

Compare each file with the complete reference below. Keep the temporary app/page.tsx focused on the Tailwind-styled Paceboard heading described above.

{
  "name": "paceboard",
  "version": "0.1.0",
  "private": true,
  "engines": {
    "node": ">=22.12.0"
  },
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "test": "vitest run"
  },
  "dependencies": {
    "next": "16.4.3",
    "react": "19.3.1",
    "react-dom": "19.3.1"
  },
  "devDependencies": {
    "@tailwindcss/postcss": "4.3.3",
    "@testing-library/dom": "10.4.2",
    "@testing-library/react": "16.3.3",
    "@types/node": "26.6.5",
    "@types/react": "19.3.0",
    "@types/react-dom": "19.3.0",
    "@vitejs/plugin-react": "6.1.2",
    "jsdom": "30.1.3",
    "postcss": "8.5.31",
    "tailwindcss": "4.3.3",
    "typescript": "7.0.2",
    "vite": "8.3.5",
    "vite-tsconfig-paths": "6.1.1",
    "vitest": "5.0.5"
  }
}

Manifest Check

Confirm that every dependency version matches the reference exactly. The Node.js engine must remain at >=22.12.0.

{
  "compilerOptions": {
    "target": "ES2017",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "react-jsx",
    "incremental": true,
    "plugins": [{ "name": "next" }],
    "paths": { "@/*": ["./*"] }
  },
  "include": [
    "next-env.d.ts",
    "**/*.ts",
    "**/*.tsx",
    ".next/types/**/*.ts",
    ".next/dev/types/**/*.ts",
    "**/*.mts"
  ],
  "exclude": ["node_modules"]
}

TypeScript Check

Confirm that strict checking and noEmit are enabled. The @/* path must map to the repository folder.

const config = {
  plugins: {
    "@tailwindcss/postcss": {},
  },
};

export default config;

PostCSS Check

Confirm that the configuration exports the Tailwind PostCSS plugin.

import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
import tsconfigPaths from "vite-tsconfig-paths";

export default defineConfig({
  plugins: [tsconfigPaths(), react()],
  test: {
    environment: "jsdom",
    setupFiles: ["./vitest.setup.ts"],
  },
});

Vitest Configuration Check

Confirm that the React plugin and path plugin are both enabled. The test environment must be jsdom.

import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";

afterEach(() => {
  cleanup();
});

Vitest Setup Check

Confirm that React Testing Library cleanup runs after every test.

# Product

- Paceboard is Project 1 in a progressive Strava-inspired application series.
- This slice covers a typed, tested activity feed with local seeded data.
- PostgreSQL persistence, authentication, GPS ingestion, and real maps are explicitly out of scope.

# Architecture

- `app/page.tsx` is the server composition boundary.
- UI code depends on `Activity`, not on fixture internals.
- Server code depends on `ActivityRepository`, not directly on seeded data.
- `ActivityId` is opaque to UI code. Adapters must return stable, unique values across reads.
- `ActivityFeed` owns transient client-only Kudos state keyed by `ActivityId`.
- Preserve `listForViewer(viewerId: string | null)` so Project 2 can supply authenticated identity.

# Commands

- Development: `npm run dev`
- Tests: `npm test`
- Production verification: `npm run build`

# Quality gates

- Keep TypeScript strict and do not introduce `any`.
- Keep source metrics numeric and format them at the presentation boundary.
- Keep activity IDs stable and unique across repository reads.
- Keep Kudos state independent per activity and derive counts from source data plus toggle state.
- Prefer role and accessible-name queries in component tests.
- Run tests and the production build after implementation changes.
- Do not add a database, authentication library, map SDK, or external API in this project.

Repository Instruction Check

Confirm that the architecture rules preserve the future viewer seam and stable activity identity. The quality gates must keep databases, authentication libraries, map SDKs, and external APIs outside this slice.

import "./globals.css";

export const metadata = {
  title: "Paceboard",
  description: "A typed and tested social running feed",
};

export default function RootLayout({
  children,
}: Readonly<{ children: React.ReactNode }>) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}

Layout Check

Confirm that the root layout imports the global stylesheet and renders its children inside the HTML body.

@import "tailwindcss";

body {
  margin: 0;
  min-height: 100vh;
}

Global Style Check

Confirm that the stylesheet imports Tailwind and sets the two body rules exactly.

Load the repository context and run Paceboard

Claude Code reads repository instructions as project memory. Starting in plan permission mode keeps this first session focused on architecture before implementation changes.

  • Return to the terminal tab that is already inside paceboard.
  • Start Claude Code in plan permission mode by running:
claude --permission-mode plan

Why Start in Plan Permission Mode?

Plan permission mode lets Claude Code inspect the repository and reason about changes before editing files. This keeps the initial architecture review separate from implementation.

  • Visualize the loaded session context by entering this command in Claude Code:
/context

What Does the Context View Prove?

The context view shows which repository instructions are available to the session. Confirm that CLAUDE.md appears in the loaded project context.

  • Open a second terminal tab using the terminal application's tab control.
  • Move the second terminal tab into the Paceboard repository by running:
cd ~/Desktop/paceboard

Why Use a Second Terminal Tab?

Claude Code remains active in the first tab. The second tab can run the development server without interrupting the planning session.

The development server keeps control of this terminal tab until you stop it. Press Ctrl+C when you want to stop the server.

  • Start the Paceboard development server by running:
npm run dev

What Does the Development Command Do?

Next.js starts the local development server and generates its TypeScript support files. Keep this terminal tab running while you check the page.

Before you load the page, do you expect to see browser-default text or the Tailwind-styled starter?

  • Open a web browser through system search.
  • Visit http://localhost:3000.

You should see a large dark Paceboard heading on a pale slate page. The spacing and typography confirm that Tailwind utilities are active.

That is the toolchain working end to end. Your browser now renders a strict TypeScript page through the pinned Next.js and Tailwind pipeline.

Page Not Loading or Missing Styles?

If the page does not load, confirm that the server terminal is still running inside ~/Desktop/paceboard. Check that the terminal shows the local address before refreshing the browser.

If the heading has browser-default styling, compare postcss.config.mjs and app/globals.css with the reference. Confirm that app/layout.tsx imports ./globals.css.

Help me debug the Paceboard development server.

Your pinned toolchain is running with repository instructions loaded. Next, you will define the stable activity contract and the replaceable data port.

Define the Activity Contract and Data Port

Your pinned Next.js toolchain now renders the temporary Paceboard heading. That starter page proves your development environment is ready for a real activity.

The first activity needs a stable TypeScript contract before the interface grows. A repository pattern keeps data access separate from presentation.

A page that imports fixtures directly has no safe replacement seam. This step creates the boundary that a future PostgreSQL adapter can implement without changing the feed components.

In this step, get ready to:
  • Define readonly activity models with opaque stable identity.
  • Isolate deterministic activity data behind a typed repository port.
  • Render the first activity through the server page with presentation formatting.
Plan the server-to-data boundary

The App Router page acts as the server composition boundary. It requests activities through ActivityRepository while the repository chooses where those activities come from.

  • Switch back to the Claude Code session from the previous step.
  • Ask Claude Code to plan the flow from ActivityRepository to app/page.tsx.
  • Require numeric source metrics in the plan.
  • Require serializable route geometry in the plan.
  • Require readonly domain models in the plan.
  • Require an opaque string-backed ActivityId in the plan.
  • Require listForViewer(viewerId: string | null) as the future viewer seam.
  • Approve the plan after it keeps fixture imports out of app/page.tsx.

Why use a repository port?

The repository interface gives the page one stable way to request activities. The seeded adapter can later give way to persistent storage while the page keeps the same dependency.

The nullable viewer parameter records today’s anonymous state. A later authentication layer can supply identity through the existing method signature.

Build the domain and repository layers

The domain model preserves activity data in computation-friendly forms. Formatting stays at the presentation boundary so future features can calculate with the original numbers.

  • Ask Claude Code to create types/activity.ts with the opaque identity definitions below:
declare const activityIdBrand: unique symbol;

export type ActivityId = string & {
  readonly [activityIdBrand]: "ActivityId";
};

export function activityId(value: string): ActivityId {
  if (value.trim().length === 0) {
    throw new Error("Activity ID cannot be empty");
  }

  return value as ActivityId;
}

export type ActivitySport = "Run" | "Ride" | "Walk";

How does opaque identity work?

  • The unique symbol brands an activity ID at the type level.
  • The activityId() factory rejects empty values before applying the brand.
  • The union limits activity sports to the supported domain values.
  • Save types/activity.ts.
  • Confirm the VS Code Explorer displays activity.ts inside the types folder.

Seeing an identity type problem?

Check that the brand property remains readonly. Confirm the factory returns value as ActivityId after the empty-value check.

Ask for targeted help if the declaration still shows a TypeScript diagnostic. Help me compare the ActivityId brand and factory with the required type contract.

  • Add the athlete and metric contracts below ActivitySport by pasting:
export interface AthleteSummary {
  readonly id: string;
  readonly name: string;
  readonly initials: string;
}

export interface ActivityMetrics {
  readonly distanceMiles: number;
  readonly elapsedSeconds: number;
  readonly averagePaceSecondsPerMile: number;
  readonly elevationGainFeet: number;
}

Why keep metrics numeric?

Numeric source values support calculations without parsing display strings. Distance formatting and pace formatting happen only when the page presents the activity.

Readonly properties prevent UI code from mutating repository results. Each layer receives a stable activity snapshot.

  • Save types/activity.ts.
  • Confirm VS Code shows no TypeScript diagnostic on AthleteSummary or ActivityMetrics.

Seeing a model diagnostic?

Check that every property includes the readonly modifier. Confirm each metric uses the number type.

Use focused assistance if the interface declarations still fail. Help me diagnose the AthleteSummary or ActivityMetrics declaration in types/activity.ts.

  • Add the serializable route contracts below ActivityMetrics by pasting:
export interface RoutePoint {
  readonly x: number;
  readonly y: number;
}

export interface RouteGeometry {
  readonly path: string;
  readonly start: RoutePoint;
  readonly finish: RoutePoint;
}

Why use serializable route geometry?

The path and coordinate values can cross the server-to-client boundary as plain data. The future route component can use them to draw an accessible inline SVG preview.

The model carries route semantics without adding a map provider. That keeps this slice deterministic.

  • Save types/activity.ts.
  • Confirm VS Code recognizes RoutePoint inside RouteGeometry.

Seeing a route type problem?

Check that start and finish both use RoutePoint. Confirm the path property uses string.

Ask for help with the exact declaration if the diagnostic remains. Help me fix the RoutePoint or RouteGeometry contract without changing its shape.

  • Complete types/activity.ts by adding the aggregate activity contract:
export interface Activity {
  readonly id: ActivityId;
  readonly athlete: AthleteSummary;
  readonly sport: ActivitySport;
  readonly title: string;
  readonly recordedAtLabel: string;
  readonly note: string;
  readonly metrics: ActivityMetrics;
  readonly route: RouteGeometry;
  readonly initialKudos: number;
}

What does the activity contract guarantee?

The aggregate composes identity and athlete data into one readonly model. It also connects metrics and route geometry through named contracts.

The initial Kudos count remains source data. A later client component can derive the displayed count from this value.

  • Save types/activity.ts.
  • Confirm VS Code shows no TypeScript diagnostic anywhere in the file.

Seeing an unresolved activity type?

Confirm every referenced interface appears earlier in types/activity.ts. Check the capitalization of ActivityId and RouteGeometry.

Get a focused comparison if the aggregate still fails. Help me find the mismatched identifier in my Activity interface.

✔️ My activity contract is complete

Your domain now has stable identity plus readonly athlete, metric, route, and activity models.

ⓧ I want to double check the full code

  • Compare types/activity.ts with this complete reference:
declare const activityIdBrand: unique symbol;

export type ActivityId = string & {
  readonly [activityIdBrand]: "ActivityId";
};

export function activityId(value: string): ActivityId {
  if (value.trim().length === 0) {
    throw new Error("Activity ID cannot be empty");
  }

  return value as ActivityId;
}

export type ActivitySport = "Run" | "Ride" | "Walk";

export interface AthleteSummary {
  readonly id: string;
  readonly name: string;
  readonly initials: string;
}

export interface ActivityMetrics {
  readonly distanceMiles: number;
  readonly elapsedSeconds: number;
  readonly averagePaceSecondsPerMile: number;
  readonly elevationGainFeet: number;
}

export interface RoutePoint {
  readonly x: number;
  readonly y: number;
}

export interface RouteGeometry {
  readonly path: string;
  readonly start: RoutePoint;
  readonly finish: RoutePoint;
}

export interface Activity {
  readonly id: ActivityId;
  readonly athlete: AthleteSummary;
  readonly sport: ActivitySport;
  readonly title: string;
  readonly recordedAtLabel: string;
  readonly note: string;
  readonly metrics: ActivityMetrics;
  readonly route: RouteGeometry;
  readonly initialKudos: number;
}

The activity contract is ready. The seeded adapter now needs deterministic records that satisfy it.

  • Ask Claude Code to create data/seeded-activities.ts with the first stable activity by using this code:
import { activityId, type Activity } from "@/types/activity";

export const seededActivities = [
  {
    id: activityId("activity-101"),
    athlete: { id: "athlete-1", name: "Maya Chen", initials: "MC" },
    sport: "Run",
    title: "Riverwalk Progression",
    recordedAtLabel: "Today at 7:12 AM",
    note: "Finished the last mile strong and held target pace.",
    metrics: {
      distanceMiles: 6.24,
      elapsedSeconds: 3108,
      averagePaceSecondsPerMile: 498,
      elevationGainFeet: 187,
    },
    route: {
      path: "M10 68 C32 54 46 76 72 56 S115 22 139 40 S172 74 210 27",
      start: { x: 10, y: 68 },
      finish: { x: 210, y: 27 },
    },
    initialKudos: 23,
  },
] satisfies readonly Activity[];

What does this fixture preserve?

The seeded record uses activityId() to create a stable branded identity. Its metrics stay numeric for later calculations.

The satisfies readonly Activity[] clause checks the fixture against the domain contract. It also preserves useful literal inference.

  • Save data/seeded-activities.ts.
  • Confirm the VS Code Explorer displays seeded-activities.ts inside the data folder.

Seeing a fixture type problem?

Check that the import path is @/types/activity. Confirm each nested object uses the property names from Activity.

Ask for a property-level comparison if the fixture remains invalid. Help me find which Riverwalk Progression property violates the Activity contract.

  • Insert the second stable activity immediately before ] satisfies readonly Activity[]; by pasting:
  {
    id: activityId("activity-102"),
    athlete: { id: "athlete-2", name: "Jordan Ellis", initials: "JE" },
    sport: "Run",
    title: "Bayfront Recovery Run",
    recordedAtLabel: "Yesterday at 6:04 PM",
    note: "Easy sunset miles with relaxed cadence and steady breathing.",
    metrics: {
      distanceMiles: 5.1,
      elapsedSeconds: 2634,
      averagePaceSecondsPerMile: 517,
      elevationGainFeet: 92,
    },
    route: {
      path: "M10 66 C39 25 64 31 78 55 S113 81 131 49 S165 17 210 31",
      start: { x: 10, y: 66 },
      finish: { x: 210, y: 31 },
    },
    initialKudos: 17,
  },

Why seed two activities?

Two records expose identity mistakes that a single fixture could hide. Their stable IDs also prepare the feed for keyed rendering and independent interaction state.

The different metrics provide realistic formatting cases. The route paths remain deterministic across every repository read.

  • Save data/seeded-activities.ts.
  • Confirm the file contains activity-101 plus activity-102 exactly once each.

Seeing an array syntax problem?

Check that the first activity ends with a comma before the second activity begins. Confirm the array closes after the second activity.

Request a narrow syntax review if the file still fails. Help me locate the missing comma or brace in seeded-activities.ts.

✔️ Both seeded activities are ready

Your deterministic fixture now contains two stable activities with numeric metrics and serializable routes.

ⓧ I want to double check the full code

  • Compare data/seeded-activities.ts with this complete reference:
import { activityId, type Activity } from "@/types/activity";

export const seededActivities = [
  {
    id: activityId("activity-101"),
    athlete: { id: "athlete-1", name: "Maya Chen", initials: "MC" },
    sport: "Run",
    title: "Riverwalk Progression",
    recordedAtLabel: "Today at 7:12 AM",
    note: "Finished the last mile strong and held target pace.",
    metrics: {
      distanceMiles: 6.24,
      elapsedSeconds: 3108,
      averagePaceSecondsPerMile: 498,
      elevationGainFeet: 187,
    },
    route: {
      path: "M10 68 C32 54 46 76 72 56 S115 22 139 40 S172 74 210 27",
      start: { x: 10, y: 68 },
      finish: { x: 210, y: 27 },
    },
    initialKudos: 23,
  },
  {
    id: activityId("activity-102"),
    athlete: { id: "athlete-2", name: "Jordan Ellis", initials: "JE" },
    sport: "Run",
    title: "Bayfront Recovery Run",
    recordedAtLabel: "Yesterday at 6:04 PM",
    note: "Easy sunset miles with relaxed cadence and steady breathing.",
    metrics: {
      distanceMiles: 5.1,
      elapsedSeconds: 2634,
      averagePaceSecondsPerMile: 517,
      elevationGainFeet: 92,
    },
    route: {
      path: "M10 66 C39 25 64 31 78 55 S113 81 131 49 S165 17 210 31",
      start: { x: 10, y: 66 },
      finish: { x: 210, y: 31 },
    },
    initialKudos: 17,
  },
] satisfies readonly Activity[];

The fixtures now need an adapter that fulfills a public data-access contract. The page will depend on that contract instead of fixture internals.

  • Ask Claude Code to create data/activity-repository.ts with this repository implementation:
import { seededActivities } from "@/data/seeded-activities";
import type { Activity } from "@/types/activity";

export interface ActivityRepository {
  listForViewer(viewerId: string | null): Promise<readonly Activity[]>;
}

class SeededActivityRepository implements ActivityRepository {
  async listForViewer(viewerId: string | null): Promise<readonly Activity[]> {
    void viewerId;
    return seededActivities;
  }
}

export const activityRepository: ActivityRepository =
  new SeededActivityRepository();

How does the data port work?

  • The interface promises an asynchronous readonly activity list.
  • The seeded adapter accepts the future viewer identity while returning deterministic local data.
  • The exported instance is typed as ActivityRepository so consumers depend on the port.
  • Save data/activity-repository.ts.
  • Confirm VS Code shows no TypeScript diagnostic on SeededActivityRepository.

Seeing an implementation mismatch?

Check that the interface and class use the same listForViewer(viewerId: string | null) signature. Confirm both return Promise<readonly Activity[]>.

Ask for a signature comparison if the class still fails. Help me compare SeededActivityRepository with ActivityRepository.

✔️ My repository port is complete

The page now has a typed data port with a deterministic local adapter behind it.

ⓧ I want to double check the full code

  • Compare data/activity-repository.ts with this complete reference:
import { seededActivities } from "@/data/seeded-activities";
import type { Activity } from "@/types/activity";

export interface ActivityRepository {
  listForViewer(viewerId: string | null): Promise<readonly Activity[]>;
}

class SeededActivityRepository implements ActivityRepository {
  async listForViewer(viewerId: string | null): Promise<readonly Activity[]> {
    void viewerId;
    return seededActivities;
  }
}

export const activityRepository: ActivityRepository =
  new SeededActivityRepository();

The repository returns source values. A small formatter module turns those numbers into labels at the edge of the interface.

  • Ask Claude Code to create lib/format-activity.ts with these presentation helpers:
export function formatDistance(distanceMiles: number): string {
  return `${distanceMiles.toFixed(2)} mi`;
}

export function formatElapsedTime(totalSeconds: number): string {
  const hours = Math.floor(totalSeconds / 3600);
  const minutes = Math.floor((totalSeconds % 3600) / 60);
  const seconds = totalSeconds % 60;
  const tail = `${minutes.toString().padStart(2, "0")}:${seconds
    .toString()
    .padStart(2, "0")}`;

  return hours > 0 ? `${hours}:${tail}` : tail;
}

export function formatPace(secondsPerMile: number): string {
  const minutes = Math.floor(secondsPerMile / 60);
  const seconds = secondsPerMile % 60;
  return `${minutes}:${seconds.toString().padStart(2, "0")} /mi`;
}

export function formatElevation(feet: number): string {
  return `${feet.toLocaleString()} ft`;
}

What do the formatters do?

  • The distance helper fixes the display to two decimal places.
  • The elapsed-time helper converts total seconds into a clock-style label.
  • The pace helper converts seconds per mile into minutes per mile.
  • The elevation helper adds localized number formatting plus the feet unit.
  • Save lib/format-activity.ts.
  • Confirm the VS Code Explorer displays format-activity.ts inside the lib folder.

Seeing a formatter syntax problem?

Check the template literal delimiters around each returned label. Confirm every formatter closes with its own brace.

Ask for a function-level review if the diagnostic remains. Help me identify the malformed formatter in lib/format-activity.ts.

✔️ My presentation helpers are ready

Your numeric activity metrics now have dedicated display formatting at the presentation boundary.

ⓧ I want to double check the full code

  • Compare lib/format-activity.ts with this complete reference:
export function formatDistance(distanceMiles: number): string {
  return `${distanceMiles.toFixed(2)} mi`;
}

export function formatElapsedTime(totalSeconds: number): string {
  const hours = Math.floor(totalSeconds / 3600);
  const minutes = Math.floor((totalSeconds % 3600) / 60);
  const seconds = totalSeconds % 60;
  const tail = `${minutes.toString().padStart(2, "0")}:${seconds
    .toString()
    .padStart(2, "0")}`;

  return hours > 0 ? `${hours}:${tail}` : tail;
}

export function formatPace(secondsPerMile: number): string {
  const minutes = Math.floor(secondsPerMile / 60);
  const seconds = secondsPerMile % 60;
  return `${minutes}:${seconds.toString().padStart(2, "0")} /mi`;
}

export function formatElevation(feet: number): string {
  return `${feet.toLocaleString()} ft`;
}
Connect the server page to the repository

The final connection turns the temporary heading into a small data-backed product. The async Server Component requests activities through the repository export.

  • Ask Claude Code to replace the temporary contents of app/page.tsx with this server composition code:
import { activityRepository } from "@/data/activity-repository";
import { formatDistance, formatPace } from "@/lib/format-activity";

export default async function Home() {
  const [activity] = await activityRepository.listForViewer(null);

  return (
    <main className="min-h-screen bg-slate-100 p-8 text-slate-950">
      <h1 className="text-3xl font-black">{activity.title}</h1>
      <p className="mt-3 text-slate-600">
        {formatDistance(activity.metrics.distanceMiles)} ·{" "}
        {formatPace(activity.metrics.averagePaceSecondsPerMile)}
      </p>
    </main>
  );
}

What does the page prove?

  • The page imports the repository export instead of the seeded fixture.
  • The null argument records the current anonymous viewer state.
  • The page formats numeric distance and pace only when rendering them.
  • The Tailwind classes preserve a visible styled result while the component layer remains temporary.
  • Save app/page.tsx.
  • Return to the development server terminal from the previous step.
  • Start the development server with the command from Step 1 if the process has stopped.

Before you refresh, which title and two formatted metrics do you expect the repository-backed page to show?

  • Return to http://localhost:3000 in your browser.
  • Refresh the page.

You should see Riverwalk Progression above 6.24 mi plus 8:18 /mi.

  • Confirm app/page.tsx imports activityRepository from @/data/activity-repository.
  • Confirm app/page.tsx contains no import from @/data/seeded-activities.

That first data-backed slice is alive. The page now renders a stable activity through the replacement seam you designed.

What limitation remains?

The page still owns its presentation markup. A reusable activity card cannot consume this layout yet.

The next step moves presentation into focused feed components while keeping this repository boundary intact.

Still seeing the starter heading?

Confirm app/page.tsx is saved. Check that the development server terminal shows a successful page compilation.

Verify the repository and formatter import paths begin with @/. Help me trace why the repository-backed page is not rendering.

✔️ I see the repository-backed activity

Your page now requests a readonly activity through the data port and formats its numeric metrics for display.

ⓧ I want to double check the full code

  • Compare app/page.tsx with this complete reference:
import { activityRepository } from "@/data/activity-repository";
import { formatDistance, formatPace } from "@/lib/format-activity";

export default async function Home() {
  const [activity] = await activityRepository.listForViewer(null);

  return (
    <main className="min-h-screen bg-slate-100 p-8 text-slate-950">
      <h1 className="text-3xl font-black">{activity.title}</h1>
      <p className="mt-3 text-slate-600">
        {formatDistance(activity.metrics.distanceMiles)} ·{" "}
        {formatPace(activity.metrics.averagePaceSecondsPerMile)}
      </p>
    </main>
  );
}

Your activity contract and data port now protect the page from fixture details. Next up, you’ll turn this baby product into a responsive feed of reusable activity components.

Compose the Responsive Activity Feed

Your typed repository now delivers stable activity data through the Next.js server page. The baby product proves that your data boundary works.

The temporary page still owns every presentation decision. Reusable React components separate those decisions into a feed, cards, route previews, and Kudos controls. Tailwind CSS then adapts the composition across wide and narrow screens.

In this step, get ready to:
  • Create reusable feed, card, route-preview, and Kudos components.
  • Connect the server page to the responsive activity feed.
  • Expose the missing interaction boundary through static Kudos controls.
Create the reusable component layer

Each component gets one responsibility. The feed handles page composition. The card translates one typed activity into a visible unit.

Why split the feed into components?

A reusable component boundary keeps route rendering, metric formatting, and control styling out of the server page. Future data adapters can return the same Activity contract without forcing UI changes.

The route remains an accessible inline SVG fixture. This keeps the first slice deterministic while preserving a clear replacement point for an interactive map.

  • Return to the Claude Code session from earlier.
  • Ask Claude Code to plan the creation of components/activity-feed.tsx, components/activity-card.tsx, components/route-preview.tsx, and components/kudos-button.tsx.
  • Require each component to accept explicit readonly props derived from Activity or RouteGeometry.
  • Require RoutePreview to expose an accessible name based on the activity title.
  • Require ActivityCard to format numeric metrics through the existing presentation helpers.
  • Require ActivityFeed to keep Kudos state fixed for this first composition.
  • Ask Claude Code to implement the approved plan.
  • In the VS Code Explorer sidebar, expand the components folder.

You should see all four component files. Their boundaries now mirror the visible parts of one activity card.

Connect the server composition

The server page remains responsible for obtaining activities from ActivityRepository. It now hands those serializable models to ActivityFeed for presentation.

  • In the VS Code Explorer sidebar, select app/page.tsx.
  • Replace the temporary presentation markup with this server composition:
import { ActivityFeed } from "@/components/activity-feed";
import { activityRepository } from "@/data/activity-repository";

export default async function Home() {
  const activities = await activityRepository.listForViewer(null);

  return <ActivityFeed activities={activities} />;
}

What does this composition do?

  • The page asks activityRepository for the readonly activity collection.
  • The null viewer preserves the future authentication seam.
  • The page passes serializable activity models into ActivityFeed without importing seeded fixtures.
  • Save app/page.tsx.
  • Return to http://localhost:3000 in your browser.
  • Refresh the Paceboard page.

You should see two activity cards beside a series-roadmap panel. Each card shows its athlete, title, note, route preview, four metrics, Kudos count, and Kudos button.

That is the component boundary paying off. Paceboard now renders the complete feed through the repository contract.

Still seeing the temporary page?

  • Confirm that app/page.tsx imports ActivityFeed from @/components/activity-feed.
  • Check the development terminal for a TypeScript error naming one of the four component files.
  • Compare the affected file with the full-code reference below.
  • Help me diagnose my Paceboard component error.
Test the responsive feed and inert controls

Responsive composition determines how the same content uses available space. The desktop grid reserves room for the roadmap. The narrow layout stacks that panel beneath the feed.

  • Widen the browser window until the roadmap sits beside the activity feed.
  • Narrow the browser window until the roadmap moves below the activity cards.

You should see the layout reflow into one column. The cards retain readable metric tiles and full-width route previews.

Before you click either Kudos control, do you expect each card to preserve its own count?

  • Click the Give kudos button on Riverwalk Progression.

The first card remains at 23 kudos. Its button keeps the same label and unpressed state.

  • Click the Give kudos button on Bayfront Recovery Run.

The second card remains at 17 kudos. The first card also remains unchanged.

  • Inspect either Kudos button with your browser's developer tools.

You should see aria-pressed remain false after the click. This unchanged state is the intended gap in the current component boundary.

What did the obstacle reveal?

The controls have accessible structure and visual styling. They do not own client-side state, so the stable activity IDs cannot drive independent interactions yet.

The feed now gives you a precise place to add that behavior. The data model and server composition can stay unchanged.

✔️ Awesome, I've got everything!

Your responsive feed renders both seeded activities. The two Kudos controls remain visibly inert when clicked.

ⓧ I'd like to double check the full code

Compare these five files with your repository. The component code below represents the complete Paceboard state for this step.

import type { RouteGeometry } from "@/types/activity";

interface RoutePreviewProps {
  readonly title: string;
  readonly route: RouteGeometry;
}

export function RoutePreview({ title, route }: RoutePreviewProps) {
  return (
    <svg
      className="min-h-36 w-full rounded-2xl bg-slate-100"
      viewBox="0 0 220 96"
      role="img"
      aria-label={`${title} route preview`}
    >
      <title>{title} route preview</title>
      <path
        d={route.path}
        fill="none"
        stroke="rgb(203 213 225)"
        strokeLinecap="round"
        strokeLinejoin="round"
        strokeWidth="10"
      />
      <path
        d={route.path}
        fill="none"
        stroke="rgb(15 118 110)"
        strokeLinecap="round"
        strokeLinejoin="round"
        strokeWidth="5"
      />
      <circle
        cx={route.start.x}
        cy={route.start.y}
        r="5"
        fill="white"
        stroke="rgb(15 118 110)"
        strokeWidth="3"
      />
      <circle
        cx={route.finish.x}
        cy={route.finish.y}
        r="5"
        fill="rgb(249 115 22)"
        stroke="white"
        strokeWidth="3"
      />
    </svg>
  );
}

How does the route preview work?

The component accepts serialized RouteGeometry data. Two layered paths create the route line. The start and finish circles give the fixture visible direction.

The role="img" attribute and title-specific accessible name expose route meaning beyond its visual shape.

interface KudosButtonProps {
  readonly activityTitle: string;
  readonly pressed: boolean;
  readonly onToggle: () => void;
}

export function KudosButton({
  activityTitle,
  pressed,
}: KudosButtonProps) {
  return (
    <button
      type="button"
      aria-label={`Give kudos to ${activityTitle}`}
      aria-pressed={pressed}
      className="rounded-full border border-orange-500 bg-white px-4 py-2 text-sm font-bold text-orange-700 transition hover:bg-orange-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-orange-500"
    >
      Give kudos
    </button>
  );
}

Why is the button static?

The component contract already includes the activity title, pressed state, and toggle callback. This step renders the accessible control without connecting that callback to an event.

The static implementation creates the visible interaction gap you just confirmed.

import { KudosButton } from "@/components/kudos-button";
import { RoutePreview } from "@/components/route-preview";
import {
  formatDistance,
  formatElapsedTime,
  formatElevation,
  formatPace,
} from "@/lib/format-activity";
import type { Activity } from "@/types/activity";

interface ActivityCardProps {
  readonly activity: Activity;
  readonly hasKudos: boolean;
  readonly onToggleKudos: () => void;
}

export function ActivityCard({
  activity,
  hasKudos,
  onToggleKudos,
}: ActivityCardProps) {
  const metrics = [
    ["Distance", formatDistance(activity.metrics.distanceMiles)],
    ["Elapsed time", formatElapsedTime(activity.metrics.elapsedSeconds)],
    ["Average pace", formatPace(activity.metrics.averagePaceSecondsPerMile)],
    ["Elevation gain", formatElevation(activity.metrics.elevationGainFeet)],
  ] as const;
  const kudosCount = activity.initialKudos + (hasKudos ? 1 : 0);

  return (
    <article
      aria-label={`${activity.title} by ${activity.athlete.name}`}
      className="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm"
    >
      <header className="flex items-center gap-3">
        <div
          aria-hidden="true"
          className="grid size-11 place-items-center rounded-full bg-teal-100 text-sm font-extrabold text-teal-800"
        >
          {activity.athlete.initials}
        </div>
        <div>
          <p className="font-extrabold text-slate-950">
            {activity.athlete.name}
          </p>
          <p className="text-sm text-slate-500">
            {activity.sport} · {activity.recordedAtLabel}
          </p>
        </div>
      </header>

      <div className="my-4">
        <h2 className="text-xl font-black tracking-tight text-slate-950">
          {activity.title}
        </h2>
        <p className="mt-1 text-sm leading-6 text-slate-600">{activity.note}</p>
      </div>

      <RoutePreview title={activity.title} route={activity.route} />

      <dl
        aria-label={`${activity.title} metrics`}
        className="my-4 grid grid-cols-2 gap-2 sm:grid-cols-4"
      >
        {metrics.map(([label, value]) => (
          <div key={label} className="rounded-xl bg-slate-50 p-3">
            <dt className="text-xs font-bold uppercase tracking-wide text-slate-500">
              {label}
            </dt>
            <dd className="mt-1 font-extrabold text-slate-900">{value}</dd>
          </div>
        ))}
      </dl>

      <footer className="flex items-center justify-between border-t border-slate-200 pt-4">
        <span aria-live="polite" className="text-sm font-bold text-slate-600">
          {kudosCount} kudos
        </span>
        <KudosButton
          activityTitle={activity.title}
          pressed={hasKudos}
          onToggle={onToggleKudos}
        />
      </footer>
    </article>
  );
}

How does the card stay data-driven?

The card accepts one readonly Activity value. Its metric array converts numeric source values through the existing formatting helpers.

The route preview and Kudos button receive narrow prop contracts. Their internal presentation can evolve without changing the repository.

import { ActivityCard } from "@/components/activity-card";
import type { Activity } from "@/types/activity";

interface ActivityFeedProps {
  readonly activities: readonly Activity[];
}

export function ActivityFeed({ activities }: ActivityFeedProps) {
  return (
    <main className="min-h-screen bg-slate-100 px-4 py-8 text-slate-950 sm:px-6">
      <div className="mx-auto grid max-w-6xl gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(240px,1fr)]">
        <section aria-labelledby="feed-title">
          <p className="text-xs font-black uppercase tracking-[0.18em] text-orange-700">
            Community activity
          </p>
          <h1 id="feed-title" className="mt-2 text-4xl font-black tracking-tight">
            Paceboard
          </h1>
          <p className="mt-2 max-w-2xl text-slate-600">
            A typed, tested running feed designed to evolve into a full platform.
          </p>

          <div className="mt-6 grid gap-5">
            {activities.map((activity) => (
              <ActivityCard
                key={activity.id}
                activity={activity}
                hasKudos={false}
                onToggleKudos={() => {}}
              />
            ))}
          </div>
        </section>

        <aside className="h-fit rounded-3xl border border-slate-200 bg-white p-5 shadow-sm lg:sticky lg:top-6">
          <p className="text-xs font-black uppercase tracking-[0.18em] text-orange-700">
            Series roadmap
          </p>
          <h2 className="mt-2 text-xl font-black">Project 1 foundation</h2>
          <ul className="mt-4 space-y-3 text-sm leading-6 text-slate-600">
            <li>Now: typed feed, seeded adapter, tests</li>
            <li>Next: authentication and PostgreSQL</li>
            <li>Later: GPS ingestion and interactive maps</li>
          </ul>
        </aside>
      </div>
    </main>
  );
}

How does the feed compose the page?

The feed maps each stable activity to an ActivityCard. The activity ID supplies the list key while hasKudos remains fixed at false.

The outer responsive grid places the roadmap beside the feed on wide screens. Narrow screens use the default single-column flow.

import { ActivityFeed } from "@/components/activity-feed";
import { activityRepository } from "@/data/activity-repository";

export default async function Home() {
  const activities = await activityRepository.listForViewer(null);

  return <ActivityFeed activities={activities} />;
}

Where is the server boundary?

The async page remains the server composition boundary. It retrieves activities through the repository interface before passing them into the feed.

Your server-rendered data now flows through a responsive reusable feed. Next, you'll add a client-side state boundary so each stable activity ID can toggle Kudos independently.

Make Kudos Toggle Independently

The responsive feed now gives each activity a reusable card. Its Kudos control still behaves like a visual placeholder because the feed has no client-side state.

A React state record keyed by the stable ActivityId gives each card an independent interaction. Deriving the displayed count from that state keeps every toggle reversible.

In this step, get ready to:
  • Create a client state boundary keyed by stable activity IDs.
  • Derive each displayed Kudos count from source data plus transient state.
  • Expose reversible behavior through accessible toggle buttons.
Create client state keyed by activity ID

The server page still loads activities through the repository. ActivityFeed becomes the client boundary because it owns the temporary Kudos state plus the click handlers.

  • Return to the VS Code window from earlier.
  • Select components/activity-feed.tsx in the Explorer sidebar.
  • Replace the imports at the top of components/activity-feed.tsx with this client directive plus import group:
"use client";

import { useState } from "react";
import { ActivityCard } from "@/components/activity-card";
import type { Activity, ActivityId } from "@/types/activity";

Why Does the Feed Become a Client Component?

  • The "use client" directive makes the feed an interactive entry point.
  • The useState import provides transient state that triggers a new render after a toggle.
  • The ActivityId import lets the state record use the same opaque identity as the repository.
  • Save components/activity-feed.tsx.
  • Refresh http://localhost:3000 in the browser from earlier.

You should still see both activity cards plus the series roadmap. This confirms that the client boundary compiles inside the existing server page.

Did the Feed Stop Compiling?

  • Confirm that "use client" is the first line in components/activity-feed.tsx.
  • Check that ActivityId is imported from @/types/activity.
  • Help me fix the client boundary in ActivityFeed.

The state record stores one optional boolean for each activity. An activity without an entry starts in the unpressed state.

  • Find the opening line of ActivityFeed in components/activity-feed.tsx.
  • Add this state declaration plus toggle function directly below that line:
  const [kudosById, setKudosById] = useState<
    Partial<Record<ActivityId, boolean>>
  >({});

  function toggleKudos(activityId: ActivityId) {
    setKudosById((current) => ({
      ...current,
      [activityId]: !current[activityId],
    }));
  }

How Does the State Stay Independent?

  • The Partial<Record<ActivityId, boolean>> type models a boolean lookup keyed by stable activity identity.
  • The functional setKudosById update receives the pending state before producing the next record.
  • The spread preserves every existing entry.
  • The computed property flips only the selected activityId.
  • Save components/activity-feed.tsx.
  • Refresh the Paceboard page in your browser.

You should see the complete feed without a compilation error. The state boundary is now ready to drive each card.

Seeing a State Type Error?

  • Confirm that the generic contains Partial<Record<ActivityId, boolean>> with both closing angle brackets.
  • Check that the updater returns an object wrapped in parentheses.
  • Help me debug the typed Kudos state update.

Each mapped card now needs to read the boolean stored under its own stable ID. The callback for that card uses the same ID when it changes state.

  • Find the activities.map block in components/activity-feed.tsx.
  • Replace that mapping block with this state-aware version:
            {activities.map((activity) => (
              <ActivityCard
                key={activity.id}
                activity={activity}
                hasKudos={Boolean(kudosById[activity.id])}
                onToggleKudos={() => toggleKudos(activity.id)}
              />
            ))}

What Does This Mapping Do?

  • The Boolean conversion turns a missing state entry into false.
  • The onToggleKudos callback closes over the current card's activity.id.
  • The existing key prop uses the same stable identity as the interaction state.
  • Save components/activity-feed.tsx.
  • Refresh the Paceboard page in your browser.

You should see both cards with their original Kudos counts. Each card now receives its own boolean state plus its own callback.

Did a Card Disappear?

  • Confirm that hasKudos reads from kudosById[activity.id].
  • Check that onToggleKudos calls toggleKudos(activity.id).
  • Help me repair the ActivityFeed mapping.
Derive the count from transient state

The seeded count remains the source value for each card. Adding either zero or one from hasKudos prevents repeated toggles from drifting away from that source.

  • Switch back to components/activity-card.tsx in the Explorer sidebar.
  • Find the line ending with ] as const; inside ActivityCard.
  • Add this derived count directly below that line:
  const kudosCount = activity.initialKudos + (hasKudos ? 1 : 0);

Why Derive the Count?

The initialKudos value stays unchanged. The boolean state contributes exactly zero or one to the displayed total.

  • Find the existing footer element in components/activity-card.tsx.
  • Replace that complete footer with this derived output plus the existing typed button props:
      <footer className="flex items-center justify-between border-t border-slate-200 pt-4">
        <span aria-live="polite" className="text-sm font-bold text-slate-600">
          {kudosCount} kudos
        </span>
        <KudosButton
          activityTitle={activity.title}
          pressed={hasKudos}
          onToggle={onToggleKudos}
        />
      </footer>

How Does the Card Expose State?

  • The live region displays kudosCount after each render.
  • The pressed prop gives the button this card's current boolean state.
  • The onToggle prop passes this card's callback into the control.
  • Save components/activity-card.tsx.
  • Refresh the Paceboard page in your browser.

Before you click the first control, do you expect its count update to affect the second card?

  • Click the first card's Give kudos button.

You should see Riverwalk Progression change from 23 kudos to 24 kudos. Bayfront Recovery Run should remain at 17 kudos.

Does the Wrong Count Change?

  • Confirm that hasKudos reads from the entry keyed by the current activity.id.
  • Check that toggleKudos updates the computed property named by activityId.
  • Help me isolate the Kudos state by activity ID.
Make the toggle accessible and reversible

The count now responds to the selected activity. The button must expose the same change through its accessible name plus its pressed state.

  • Switch back to components/kudos-button.tsx in the Explorer sidebar.
  • Replace the complete KudosButton function with this dynamic implementation:
export function KudosButton({
  activityTitle,
  pressed,
  onToggle,
}: KudosButtonProps) {
  const action = pressed ? "Remove kudos from" : "Give kudos to";

  return (
    <button
      type="button"
      aria-label={`${action} ${activityTitle}`}
      aria-pressed={pressed}
      onClick={onToggle}
      className={`rounded-full border px-4 py-2 text-sm font-bold transition focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-orange-500 ${
        pressed
          ? "border-orange-500 bg-orange-500 text-white"
          : "border-orange-500 bg-white text-orange-700 hover:bg-orange-50"
      }`}
    >
      {pressed ? "Kudos given" : "Give kudos"}
    </button>
  );
}

What Makes This an Accessible Toggle?

  • The dynamic aria-label names the next action plus the specific activity.
  • The aria-pressed attribute exposes the current toggle state.
  • The visible label switches between Give kudos and Kudos given.
  • The conditional classes make the selected state visible without duplicating state.
  • Save components/kudos-button.tsx.
  • Refresh the Paceboard page in your browser.

Before you test the finished interaction, do you expect toggling Riverwalk Progression twice to restore its original count?

  • Click the Riverwalk Progression Give kudos button.

You should see 24 kudos plus the visible label Kudos given.

  • Inspect the Riverwalk Progression Kudos button with your browser's element inspector.

You should see aria-pressed="true" plus an accessible name of Remove kudos from Riverwalk Progression.

  • Click the Riverwalk Progression Kudos given button.

You should see the count return to 23 kudos. The button should return to aria-pressed="false".

  • Click the Bayfront Recovery Run Give kudos button.

You should see Bayfront Recovery Run change to 18 kudos. Riverwalk Progression should remain at 23 kudos.

That state boundary is working. Each card now has a reversible interaction tied to its stable activity identity.

Does the Button Stay Visually Static?

  • Confirm that the button text uses pressed ? "Kudos given" : "Give kudos".
  • Check that aria-pressed receives the pressed prop without quotation marks.
  • Verify that onClick receives onToggle.
  • Help me debug the dynamic Kudos button.

✔️ Awesome, I've got everything!

Your three components now use stable activity identity to keep each Kudos interaction independent.

ⓧ I'd like to double check the full code

  • Compare these three files with your implementation.
"use client";

import { useState } from "react";
import { ActivityCard } from "@/components/activity-card";
import type { Activity, ActivityId } from "@/types/activity";

interface ActivityFeedProps {
  readonly activities: readonly Activity[];
}

export function ActivityFeed({ activities }: ActivityFeedProps) {
  const [kudosById, setKudosById] = useState<
    Partial<Record<ActivityId, boolean>>
  >({});

  function toggleKudos(activityId: ActivityId) {
    setKudosById((current) => ({
      ...current,
      [activityId]: !current[activityId],
    }));
  }

  return (
    <main className="min-h-screen bg-slate-100 px-4 py-8 text-slate-950 sm:px-6">
      <div className="mx-auto grid max-w-6xl gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(240px,1fr)]">
        <section aria-labelledby="feed-title">
          <p className="text-xs font-black uppercase tracking-[0.18em] text-orange-700">
            Community activity
          </p>
          <h1 id="feed-title" className="mt-2 text-4xl font-black tracking-tight">
            Paceboard
          </h1>
          <p className="mt-2 max-w-2xl text-slate-600">
            A typed, tested running feed designed to evolve into a full platform.
          </p>

          <div className="mt-6 grid gap-5">
            {activities.map((activity) => (
              <ActivityCard
                key={activity.id}
                activity={activity}
                hasKudos={Boolean(kudosById[activity.id])}
                onToggleKudos={() => toggleKudos(activity.id)}
              />
            ))}
          </div>
        </section>

        <aside className="h-fit rounded-3xl border border-slate-200 bg-white p-5 shadow-sm lg:sticky lg:top-6">
          <p className="text-xs font-black uppercase tracking-[0.18em] text-orange-700">
            Series roadmap
          </p>
          <h2 className="mt-2 text-xl font-black">Project 1 foundation</h2>
          <ul className="mt-4 space-y-3 text-sm leading-6 text-slate-600">
            <li>Now: typed feed, seeded adapter, tests</li>
            <li>Next: authentication and PostgreSQL</li>
            <li>Later: GPS ingestion and interactive maps</li>
          </ul>
        </aside>
      </div>
    </main>
  );
}
import { KudosButton } from "@/components/kudos-button";
import { RoutePreview } from "@/components/route-preview";
import {
  formatDistance,
  formatElapsedTime,
  formatElevation,
  formatPace,
} from "@/lib/format-activity";
import type { Activity } from "@/types/activity";

interface ActivityCardProps {
  readonly activity: Activity;
  readonly hasKudos: boolean;
  readonly onToggleKudos: () => void;
}

export function ActivityCard({
  activity,
  hasKudos,
  onToggleKudos,
}: ActivityCardProps) {
  const metrics = [
    ["Distance", formatDistance(activity.metrics.distanceMiles)],
    ["Elapsed time", formatElapsedTime(activity.metrics.elapsedSeconds)],
    ["Average pace", formatPace(activity.metrics.averagePaceSecondsPerMile)],
    ["Elevation gain", formatElevation(activity.metrics.elevationGainFeet)],
  ] as const;
  const kudosCount = activity.initialKudos + (hasKudos ? 1 : 0);

  return (
    <article
      aria-label={`${activity.title} by ${activity.athlete.name}`}
      className="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm"
    >
      <header className="flex items-center gap-3">
        <div
          aria-hidden="true"
          className="grid size-11 place-items-center rounded-full bg-teal-100 text-sm font-extrabold text-teal-800"
        >
          {activity.athlete.initials}
        </div>
        <div>
          <p className="font-extrabold text-slate-950">
            {activity.athlete.name}
          </p>
          <p className="text-sm text-slate-500">
            {activity.sport} · {activity.recordedAtLabel}
          </p>
        </div>
      </header>

      <div className="my-4">
        <h2 className="text-xl font-black tracking-tight text-slate-950">
          {activity.title}
        </h2>
        <p className="mt-1 text-sm leading-6 text-slate-600">{activity.note}</p>
      </div>

      <RoutePreview title={activity.title} route={activity.route} />

      <dl
        aria-label={`${activity.title} metrics`}
        className="my-4 grid grid-cols-2 gap-2 sm:grid-cols-4"
      >
        {metrics.map(([label, value]) => (
          <div key={label} className="rounded-xl bg-slate-50 p-3">
            <dt className="text-xs font-bold uppercase tracking-wide text-slate-500">
              {label}
            </dt>
            <dd className="mt-1 font-extrabold text-slate-900">{value}</dd>
          </div>
        ))}
      </dl>

      <footer className="flex items-center justify-between border-t border-slate-200 pt-4">
        <span aria-live="polite" className="text-sm font-bold text-slate-600">
          {kudosCount} kudos
        </span>
        <KudosButton
          activityTitle={activity.title}
          pressed={hasKudos}
          onToggle={onToggleKudos}
        />
      </footer>
    </article>
  );
}
interface KudosButtonProps {
  readonly activityTitle: string;
  readonly pressed: boolean;
  readonly onToggle: () => void;
}

export function KudosButton({
  activityTitle,
  pressed,
  onToggle,
}: KudosButtonProps) {
  const action = pressed ? "Remove kudos from" : "Give kudos to";

  return (
    <button
      type="button"
      aria-label={`${action} ${activityTitle}`}
      aria-pressed={pressed}
      onClick={onToggle}
      className={`rounded-full border px-4 py-2 text-sm font-bold transition focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-orange-500 ${
        pressed
          ? "border-orange-500 bg-orange-500 text-white"
          : "border-orange-500 bg-white text-orange-700 hover:bg-orange-50"
      }`}
    >
      {pressed ? "Kudos given" : "Give kudos"}
    </button>
  );
}

Your feed now supports independent client interaction without moving repository access out of the server boundary. Next up, you'll prove these contracts with focused tests plus a production build.

Prove the Slice and Document Its Boundaries

Paceboard now has independently reversible Kudos controls backed by stable activity identity. The working interface proves that the client boundary behaves correctly.

A progressive portfolio series also needs executable evidence for its contracts. This step adds that evidence through focused tests. It also records the architecture seams that future projects can replace.

In this step, get ready to:
  • Add focused contract tests for the activity feed.
  • Map the current architecture to its future integration boundaries.
  • Collect executable quality-gate evidence.
Test the feed contracts

The browser proves the happy path manually. Vitest and React Testing Library turn the same behavior into repeatable evidence.

The first test scopes its queries to the Riverwalk card. This proves that formatting stays at the presentation boundary while the route remains accessible.

  • Return to the paceboard folder in the VS Code Explorer sidebar.
  • Create components/activity-feed.test.tsx with the Explorer sidebar's new-file control.
  • Add the rendering contract by pasting this code into components/activity-feed.test.tsx:
import { fireEvent, render, screen, within } from "@testing-library/react";
import { describe, expect, test } from "vitest";
import { ActivityFeed } from "@/components/activity-feed";
import { activityRepository } from "@/data/activity-repository";
import { seededActivities } from "@/data/seeded-activities";

describe("ActivityFeed", () => {
  test("renders formatted running metrics and an accessible route", () => {
    render(<ActivityFeed activities={seededActivities} />);

    const card = screen.getByRole("article", {
      name: "Riverwalk Progression by Maya Chen",
    });
    const cardQueries = within(card);

    expect(cardQueries.getByText("6.24 mi")).toBeDefined();
    expect(cardQueries.getByText("51:48")).toBeDefined();
    expect(cardQueries.getByText("8:18 /mi")).toBeDefined();
    expect(cardQueries.getByText("187 ft")).toBeDefined();
    expect(
      cardQueries.getByRole("img", {
        name: "Riverwalk Progression route preview",
      }),
    ).toBeDefined();
  });
});

What does this test prove?

  • The render() call mounts the client component in jsdom.
  • The within(card) helper keeps every metric assertion inside the Riverwalk activity.
  • The role query verifies that the route preview exposes its activity-specific accessible name.
  • Save components/activity-feed.test.tsx.
  • Verify the rendering contract by running this command in the Terminal tab from earlier:
npm test

You should see one passing test for the formatted metrics and accessible route.

Is the rendering test failing?

  • Check that the file path is exactly components/activity-feed.test.tsx.
  • Compare each metric string with the values shown in the Riverwalk card.
  • Ask for help with the failing query.

That first passing test gives the visible feed an executable rendering contract. The next contract targets identity below the interface.

  • In components/activity-feed.test.tsx, find the final }); that closes the describe() block.
  • Insert the repository identity test immediately above that final line:
  test("preserves stable, unique activity IDs across repository reads", async () => {
    const firstRead = await activityRepository.listForViewer(null);
    const secondRead = await activityRepository.listForViewer(null);
    const firstIds = firstRead.map((activity) => activity.id);
    const secondIds = secondRead.map((activity) => activity.id);

    expect(firstIds).toEqual(["activity-101", "activity-102"]);
    expect(secondIds).toEqual(firstIds);
    expect(new Set(firstIds).size).toBe(firstIds.length);
  });

What does the identity test prove?

  • Two calls to activityRepository.listForViewer(null) exercise the data port through its public contract.
  • The equality assertion proves that repository reads preserve the same opaque IDs.
  • The Set comparison proves that both activities have unique identities.
  • Save components/activity-feed.test.tsx.
  • Verify the identity contract by running the test suite again:
npm test

You should see two passing tests. The second test proves that both repository reads return the same two unique IDs.

Is the identity test failing?

  • Confirm that the test calls activityRepository.listForViewer(null) twice.
  • Check that the expected IDs are activity-101 and activity-102.
  • Ask for help comparing the repository reads.

Stable identity matters because the feed keys transient Kudos state by ActivityId. The final test connects that architecture rule to observable interaction behavior.

  • Insert the interaction test scaffold immediately above the final }); in components/activity-feed.test.tsx:
  test("toggles Kudos independently and keeps counts accurate", () => {
    render(<ActivityFeed activities={seededActivities} />);

    const firstCard = screen.getByRole("article", {
      name: "Riverwalk Progression by Maya Chen",
    });
    const secondCard = screen.getByRole("article", {
      name: "Bayfront Recovery Run by Jordan Ellis",
    });
    const first = within(firstCard);
    const second = within(secondCard);
    const firstButton = first.getByRole("button", {
      name: "Give kudos to Riverwalk Progression",
    });
  });

Why query by role and name?

Role queries locate the same controls that assistive technology exposes. Activity-specific names also prevent one card's button from being confused with another card's button.

  • Save components/activity-feed.test.tsx.
  • Verify that all three tests can locate their starting elements by running:
npm test

You should see three passing tests. The third test now proves that both cards and the first accessible Kudos control can be located.

Can the test find the Kudos button?

  • Confirm that the first button query uses the accessible name Give kudos to Riverwalk Progression.
  • Check that ActivityFeed receives seededActivities.
  • Ask for help inspecting the accessible button name.

The scaffold proves that the interaction targets are accessible. Now the assertions can exercise both directions of the reversible state transition.

  • In the third test, find the line that closes the firstButton role query.
  • Insert the interaction assertions immediately below that line:

    fireEvent.click(firstButton);
    expect(firstButton.getAttribute("aria-pressed")).toBe("true");
    expect(first.getByText("24 kudos")).toBeDefined();
    expect(second.getByText("17 kudos")).toBeDefined();

    fireEvent.click(firstButton);
    expect(firstButton.getAttribute("aria-pressed")).toBe("false");
    expect(first.getByText("23 kudos")).toBeDefined();

    fireEvent.click(
      second.getByRole("button", {
        name: "Give kudos to Bayfront Recovery Run",
      }),
    );
    expect(second.getByText("18 kudos")).toBeDefined();
    expect(first.getByText("23 kudos")).toBeDefined();

What do these assertions protect?

  • The first click checks the pressed state. It also checks the increment from 23 kudos to 24 kudos.
  • The second click checks the reversible path back to the original count.
  • The final click proves that the Bayfront count can change without corrupting the Riverwalk count.

Before you run the completed suite, do you expect all three contracts to pass together?

  • Save components/activity-feed.test.tsx.
  • Run the completed test suite with this command:
npm test

You should see three passing tests covering rendering, stable identity, and independent reversible Kudos behavior.

Is the interaction test failing?

  • Check that both fireEvent.click() calls for Riverwalk use the same firstButton reference.
  • Confirm that the Bayfront query remains scoped with within(secondCard).
  • Ask for help tracing the count transition.

✔️ Awesome, I've got everything!

Your completed components/activity-feed.test.tsx now protects all three feed contracts.

ⓧ I'd like to double check the full code

Compare your complete test file with this reference:

import { fireEvent, render, screen, within } from "@testing-library/react";
import { describe, expect, test } from "vitest";
import { ActivityFeed } from "@/components/activity-feed";
import { activityRepository } from "@/data/activity-repository";
import { seededActivities } from "@/data/seeded-activities";

describe("ActivityFeed", () => {
  test("renders formatted running metrics and an accessible route", () => {
    render(<ActivityFeed activities={seededActivities} />);

    const card = screen.getByRole("article", {
      name: "Riverwalk Progression by Maya Chen",
    });
    const cardQueries = within(card);

    expect(cardQueries.getByText("6.24 mi")).toBeDefined();
    expect(cardQueries.getByText("51:48")).toBeDefined();
    expect(cardQueries.getByText("8:18 /mi")).toBeDefined();
    expect(cardQueries.getByText("187 ft")).toBeDefined();
    expect(
      cardQueries.getByRole("img", {
        name: "Riverwalk Progression route preview",
      }),
    ).toBeDefined();
  });

  test("preserves stable, unique activity IDs across repository reads", async () => {
    const firstRead = await activityRepository.listForViewer(null);
    const secondRead = await activityRepository.listForViewer(null);
    const firstIds = firstRead.map((activity) => activity.id);
    const secondIds = secondRead.map((activity) => activity.id);

    expect(firstIds).toEqual(["activity-101", "activity-102"]);
    expect(secondIds).toEqual(firstIds);
    expect(new Set(firstIds).size).toBe(firstIds.length);
  });

  test("toggles Kudos independently and keeps counts accurate", () => {
    render(<ActivityFeed activities={seededActivities} />);

    const firstCard = screen.getByRole("article", {
      name: "Riverwalk Progression by Maya Chen",
    });
    const secondCard = screen.getByRole("article", {
      name: "Bayfront Recovery Run by Jordan Ellis",
    });
    const first = within(firstCard);
    const second = within(secondCard);
    const firstButton = first.getByRole("button", {
      name: "Give kudos to Riverwalk Progression",
    });

    fireEvent.click(firstButton);
    expect(firstButton.getAttribute("aria-pressed")).toBe("true");
    expect(first.getByText("24 kudos")).toBeDefined();
    expect(second.getByText("17 kudos")).toBeDefined();

    fireEvent.click(firstButton);
    expect(firstButton.getAttribute("aria-pressed")).toBe("false");
    expect(first.getByText("23 kudos")).toBeDefined();

    fireEvent.click(
      second.getByRole("button", {
        name: "Give kudos to Bayfront Recovery Run",
      }),
    );
    expect(second.getByText("18 kudos")).toBeDefined();
    expect(first.getByText("23 kudos")).toBeDefined();
  });
});
Document the architecture boundaries

Tests explain what the slice guarantees today. The README.md file explains how the design evolves without coupling the interface to future infrastructure.

A Mermaid flowchart keeps that architecture visible inside GitHub. The surrounding prose records each deliberate limitation.

  • Create README.md inside the paceboard folder with the VS Code Explorer sidebar's new-file control.
  • Add the project introduction and architecture diagram by pasting this first section:
# Paceboard

Paceboard is Project 1 in a progressive, portfolio-ready Strava-inspired application series. This slice proves stable activity identity, a typed data boundary, reusable activity-feed components, accessible client interaction, focused tests, and production verification without paid infrastructure.

## Commands

```bash
npm install
npm run dev
npm test
npm run build
```

Open `http://localhost:3000` during development.

## Architecture

```mermaid
flowchart LR
  Page["App Router page<br/>Server Component"] --> Port["ActivityRepository<br/>data-access port"]
  Port --> Seed["SeededActivityRepository<br/>local fixture adapter"]
  Page --> Feed["ActivityFeed<br/>Client Component"]
  Feed --> Card["ActivityCard"]
  Card --> Route["RoutePreview"]
  Card --> Kudos["KudosButton"]
  Auth["Future authentication"] -. "viewerId" .-> Page
  Postgres["Future PostgreSQL adapter"] -. "implements port" .-> Port
  GPS["Future GPS ingestion"] -. "stores route data" .-> Postgres
  Maps["Future interactive map adapter"] -. "replaces SVG view" .-> Route
```

What does the diagram communicate?

  • The solid connections show the working flow from the server page to the data port and client components.
  • The dotted connections show where future authentication and persistence adapters enter.
  • The route boundary shows that an interactive map can replace the current SVG view without changing the activity model.
  • Save README.md.
  • Confirm that the source contains the complete flow from Page to Port and Feed.

Does the diagram look incomplete?

  • Check that the fenced block starts with mermaid.
  • Confirm that every node label has matching quotation marks and brackets.
  • Ask for help checking the flowchart syntax.

The diagram shows replaceable seams at a glance. The next section states what those seams mean for the current product and the next two projects.

  • Append the current boundaries and verification evidence to README.md by pasting this section below the diagram:

## Current boundaries

- Activity data is deterministic and in memory.
- `ActivityId` is an opaque string to UI code. Repository adapters must return stable, unique IDs, while a future PostgreSQL adapter can use UUID values without changing components.
- `listForViewer(viewerId: string | null)` defines where authenticated identity enters later.
- `ActivityRepository` isolates the UI and page from fixture or database details.
- Kudos are local component state. They reset on refresh and do not represent server-authoritative counts.
- Route previews are accessible inline SVG fixtures, not GPS traces or map tiles.
- The project tests synchronous client components and the seeded repository contract. The async page composition is verified by `next build`.
- Paceboard intentionally allows Kudos to be toggled off for this project's interaction requirement, unlike Strava's current one-way Kudos behavior.

## Progressive series

1. Project 1: Stable identity, typed feed architecture, local adapter, interactions, and tests
2. Project 2: Authentication, PostgreSQL schema, repository adapter, and persisted Kudos
3. Project 3: GPS activity ingestion, interactive maps, privacy controls, and richer analysis

## Verification evidence

- `npm test` verifies metric rendering, stable unique IDs, independent Kudos toggles, and accurate counts.
- `npm run build` type-checks and creates the production build.
- `/feed-review` performs the expanded Claude Code acceptance review after the Secret Mission.

Why document limitations?

The boundaries distinguish tested behavior from planned integrations. That honesty makes the architecture credible because each future system has a named replacement seam.

The verification section connects each quality claim to an executable command. A reviewer can reproduce the evidence instead of trusting a screenshot alone.

  • Save README.md.
  • Confirm that the file names authentication, PostgreSQL, GPS ingestion, and interactive maps as future boundaries.

Is a future boundary missing?

  • Compare the diagram's dotted connections with the three progressive-series projects.
  • Check that the current-boundaries list describes fixture data and local Kudos state.
  • Ask for help auditing the architecture story.

✔️ Awesome, I've got everything!

Your README.md now connects the tested slice to its planned evolution.

ⓧ I'd like to double check the full code

Compare your complete documentation file with this reference:

# Paceboard

Paceboard is Project 1 in a progressive, portfolio-ready Strava-inspired application series. This slice proves stable activity identity, a typed data boundary, reusable activity-feed components, accessible client interaction, focused tests, and production verification without paid infrastructure.

## Commands

```bash
npm install
npm run dev
npm test
npm run build
```

Open `http://localhost:3000` during development.

## Architecture

```mermaid
flowchart LR
  Page["App Router page<br/>Server Component"] --> Port["ActivityRepository<br/>data-access port"]
  Port --> Seed["SeededActivityRepository<br/>local fixture adapter"]
  Page --> Feed["ActivityFeed<br/>Client Component"]
  Feed --> Card["ActivityCard"]
  Card --> Route["RoutePreview"]
  Card --> Kudos["KudosButton"]
  Auth["Future authentication"] -. "viewerId" .-> Page
  Postgres["Future PostgreSQL adapter"] -. "implements port" .-> Port
  GPS["Future GPS ingestion"] -. "stores route data" .-> Postgres
  Maps["Future interactive map adapter"] -. "replaces SVG view" .-> Route
```

## Current boundaries

- Activity data is deterministic and in memory.
- `ActivityId` is an opaque string to UI code. Repository adapters must return stable, unique IDs, while a future PostgreSQL adapter can use UUID values without changing components.
- `listForViewer(viewerId: string | null)` defines where authenticated identity enters later.
- `ActivityRepository` isolates the UI and page from fixture or database details.
- Kudos are local component state. They reset on refresh and do not represent server-authoritative counts.
- Route previews are accessible inline SVG fixtures, not GPS traces or map tiles.
- The project tests synchronous client components and the seeded repository contract. The async page composition is verified by `next build`.
- Paceboard intentionally allows Kudos to be toggled off for this project's interaction requirement, unlike Strava's current one-way Kudos behavior.

## Progressive series

1. Project 1: Stable identity, typed feed architecture, local adapter, interactions, and tests
2. Project 2: Authentication, PostgreSQL schema, repository adapter, and persisted Kudos
3. Project 3: GPS activity ingestion, interactive maps, privacy controls, and richer analysis

## Verification evidence

- `npm test` verifies metric rendering, stable unique IDs, independent Kudos toggles, and accurate counts.
- `npm run build` type-checks and creates the production build.
- `/feed-review` performs the expanded Claude Code acceptance review after the Secret Mission.
Run the quality gates

The test suite checks synchronous component and repository contracts. The Next.js production build verifies the async server composition boundary.

Before you run both gates, do you expect the implementation and documentation to describe the same architecture?

  • Return to the Terminal tab from earlier.
  • Run the final test verification with this command:
npm test

You should see all three tests pass. This confirms the rendering, identity, and interaction contracts together.

Did a final test fail?

  • Read the first failing assertion before changing implementation code.
  • Compare the failure with the matching contract in README.md.
  • Ask for help finding the root cause.
  • Run the production verification with this command:
npm run build

You should see a successful production build. This proves that the async page composition passes the framework's production checks.

Did the production build fail?

  • Start with the first TypeScript or build error in the terminal output.
  • Fix the reported source mismatch instead of weakening strict types.
  • Ask for help tracing the build failure.
  • Switch back to the Claude Code session from earlier.
  • Inspect the working-tree changes with this command:
/diff

You should see the new test suite and documentation changes. Confirm that the diff introduces no database, authentication library, map SDK, or external API.

Does the diff include unrelated changes?

  • Identify every changed file outside components/activity-feed.test.tsx and README.md.
  • Keep only changes that support the stated acceptance criteria.
  • Ask for help auditing the diff.

You now have executable evidence for Paceboard's core contracts. The repository also explains exactly how this local slice can evolve without rewriting its feed components.

Secret mission

Turn the Acceptance Checklist into a Claude Code Skill

Turn Paceboard's acceptance criteria into a reusable `/feed-review` workflow. The skill checks executable evidence plus architecture contracts. It reports concise findings without editing files.

Clean Up Your Resources

Clean Up Your Resources

Paceboard runs entirely on your machine. It creates no cloud resources, paid API usage, or new ongoing charges.

Decide whether to keep the repository available, pause your local processes, or delete every project file.

Resources you used:

  • The local paceboard repository, including its source files, tests, README.md, CLAUDE.md, .claude/skills/feed-review/SKILL.md, package-lock.json, and node_modules.
  • The local Next.js development server while it is active.
  • The local Vitest and jsdom test infrastructure stored inside the repository.
  • The reusable Claude Code review skill stored inside the repository.

Keep everything running

No action needed while you are actively extending Paceboard. Choose this option if the repository remains your foundation for the next project.

  • Stop the development server when you finish the current session by pressing Ctrl+C in its Terminal tab.
  • Retain the paceboard repository with its passing tests, production build evidence, architecture documentation, and review skill.
  • Keep node_modules if you want the fastest restart without reinstalling dependencies.
  • Leave the project on your machine without worrying about cloud charges.

Pause - I'll come back to this later

Shut down the running processes to free up memory while retaining every Paceboard file.

  • Return to the Terminal tab running the development server.
  • Press Ctrl+C to stop the server.
  • Close the Terminal tab containing your Claude Code session.
  • Close VS Code.

When you return, run the resume commands from inside the paceboard folder.

  • Resume the latest Claude Code conversation by running:
claude -c

What Does This Resume?

This restores your most recent Claude Code conversation for the current repository. Your repository-level instructions remain available.

  • Start the local development server in a second Terminal tab by running:
npm run dev

What Does This Restart?

This starts Paceboard from the files and dependencies you retained. You can continue from the same tested application state.

Having Trouble Resuming?

Confirm that both commands run from inside the paceboard folder.

Ask for help with the local session if the conversation or server does not resume: Help me diagnose why my Paceboard Claude Code session or development server will not resume.

Delete - I don't want to use this again

Deleting the repository permanently removes every Paceboard file, dependency, test, document, and local skill. No remote resource remains after this cleanup.

  • Return to the Terminal tab running the development server.
  • Press Ctrl+C to stop the server.
  • Close the Terminal tab containing Claude Code.
  • Close VS Code.

macOS

  • Move out of paceboard, delete it, and list the remaining folders by running:
cd ..
rm -rf paceboard
ls
What Does This Cleanup Do?
  • The cd command moves Terminal to the folder that contains paceboard.
  • The rm command permanently removes the repository and everything inside it.
  • The ls command shows the folders that remain.

You should see the parent folder listing without paceboard. That completes the full local cleanup.

Still See the Repository?
  • Confirm that the development server has stopped before repeating the cleanup.
  • Confirm that Terminal is in the folder directly above paceboard.

Get help diagnosing the remaining folder: Help me remove my local Paceboard repository safely.

Windows

  • Move out of paceboard, delete it, and list the remaining folders by running:
cd ..
rmdir /s /q paceboard
dir
What Does This Cleanup Do?
  • The cd command moves the terminal to the folder that contains paceboard.
  • The rmdir command permanently removes the repository and everything inside it.
  • The dir command shows the folders that remain.

You should see the parent folder listing without paceboard. That completes the full local cleanup.

Still See the Repository?
  • Confirm that the development server has stopped before repeating the cleanup.
  • Confirm that VS Code and Claude Code are no longer using files inside paceboard.

Get help diagnosing the remaining folder: Help me remove my local Paceboard repository safely.

Nice Work!

Nice Work!

That is a wrap! Your Paceboard repository now delivers a responsive activity feed through a replaceable data boundary. Focused tests plus a passing production build prove that the slice is ready to evolve.

You've learned how to:

  • Build a typed data boundary with readonly TypeScript models plus opaque ActivityId values. The ActivityRepository seam can later accept authenticated viewers or a PostgreSQL adapter without changing feed components.
  • Compose a responsive activity feed from reusable React components styled with Tailwind CSS. Accessible SVG route previews show each course. Reversible Kudos controls keep every activity independent.
  • Prove the slice with focused Vitest interaction tests plus a successful Next.js production build. Capture the architecture in a Mermaid diagram with explicit authentication, PostgreSQL, GPS ingestion, and interactive-map boundaries.
  • Secret Mission: Strengthened a reusable /feed-review Claude Code skill that reviews tests, build health, stable identity, accessibility, architecture boundaries, and documentation. Its final read-only report confirms every acceptance criterion with PASS evidence.

Ready to quiz yourself?