C4 — Build the Reading Progress Feature

Summary — what this page covers The Day 1 finale: build your first real feature end-to-end — spec → code → tests → a debugging exercise. You'll add a Reading Progress feature to BookTracker (an entity, a pure state machine, a service, two endpoints, a migration, and tests), then deliberately plant a bug and use Claude Code to find its root cause and fix the source. This is also the surface Day 2 builds on.

Time: ~75 minutes · Format: hands-on, solo · You start from: checkpoint/c3-github-mcp · You end at: checkpoint/c4-reading-progress

By C3 you have a clean BookTracker, a CLAUDE.md, path-scoped rules in .claude/rules/, a test-writer subagent, a PostToolUse build hook, and GitHub MCP wired up. Now you put it all to work on a feature that matters. Reading Progress tracks how far a reader is through a book — current page, status, and start/finish dates — governed by a small forward-only state machine. You'll write the spec first, build against it, test every rule, and finish with a deliberate debugging loop. Keep the conventions tight: this exact code becomes the agent tool in C6 and the MCP tool in C8, so IReadingProgressService is a stability contract, not throwaway Day 1 code.


1. Prerequisites

This is a Day 1 (Claude Code) lab, so you need:

Tool Why Check
Claude Code CLI runs the agent against your repo claude --version
A paid Claude plan required to use Claude Code sign in when prompted
EF Core tools adds the migration dotnet ef --version (else dotnet tool install -g dotnet-ef)

Day 1 uses Claude Code with a paid Claude plan. (Day 2's labs use the Anthropic API and a separate API key — you don't need that here.)


2. Continue from your C3 work

Keep using your working branch if you have one. Otherwise start fresh from the C3 checkpoint, then launch Claude Code from the solution folder so it picks up CLAUDE.md, the rules, the subagent, and the hook:

# only if you don't already have a working branch at C3
git switch -c lab checkpoint/c3-github-mcp

cd src/BookTracker
claude

Each lab starts from the previous checkpoint, and the matching tag is the answer key. If you get stuck, diff against checkpoint/c4-reading-progress — but build it yourself first.


3. Part A — Write the spec first

Specs come before code. Ask Claude Code to draft specs/reading-progress.md as a 6-section spec. Give it the shape and the rules; review what it produces.

Write specs/reading-progress.md for a Reading Progress feature. Use 6 sections:
1. Context & scope — track current page, reading status, start/finish dates; one record per book.
   Out of scope: per-user progress, history/audit, partial-page tracking.
2. API contract — GET and PUT /api/books/{id}/reading-progress; status codes 200/400/404.
3. Data model & persistence — one row per BookId (unique); TotalPages derives from Book.TotalPages
   and is NOT stored; status stored as a string; dates as DateOnly?; an AddReadingProgress migration.
4. Business rules — forward-only WantToRead -> Reading -> Completed; page 0..TotalPages inclusive;
   entering Reading stamps StartedOn (today), entering Completed stamps FinishedOn (today) and pins
   CurrentPage to TotalPages.
5. Tests required — list the rule cases.
6. Prohibitions & acceptance — no skipping to Completed; Completed is terminal; page never out of bounds.

You should end up with a tight spec that names the two routes, the data model, the state machine, and at least three prohibitions. This is the contract the rest of the lab builds against.


4. Part B — Build the domain (Core)

Now build the feature, starting at the center — BookTracker.Core, which depends on nothing. Drive Claude file by file so each piece stays small and reviewable. Create:

  • Entities/ReadingStatus.csenum ReadingStatus { WantToRead, Reading, Completed }.
  • Entities/ReadingProgress.csId, BookId, Book? Book (nav), CurrentPage, Status, StartedOn/FinishedOn as DateOnly?. No TotalPages — it's derived from Book.TotalPages.
  • Dtos/ReadingProgressDto.cs — holds two records:
    • ReadingProgressDto: BookId, CurrentPage, TotalPages (from the book), string Status, StartedOn, FinishedOn, and a computed PercentComplete.

    • UpdateReadingProgressRequest: CurrentPage, string Status. (Dates are derived by the rules, never client-supplied.)

Then build the heart of the feature — the pure state machine in Core/Domain/ReadingProgressRules.cs. No EF, no I/O — just logic the service calls.

Create Core/Domain/ReadingProgressRules.cs — a pure, EF-free state machine. Shape:
  Apply(ReadingProgress current, int requestedPage, ReadingStatus requestedStatus, int totalPages, DateOnly today)
It mutates `current` in place on success and returns a RuleResult(bool Ok, string? Error).

5. Encode the rules

Make ReadingProgressRules.Apply enforce exactly this — these are the rules every test in Part D will check:

  • Forward-only status: WantToRead → Reading → Completed. Same → same is a no-op.

  • Completed is terminal — reject any transition out of it (this is the cell the Part-E bug will break).

  • Page bounds: reject if CurrentPage < 0 or CurrentPage > totalPages (inclusive upper bound).

  • Entering Reading: set StartedOn = today if it's null.

  • Entering Completed: set FinishedOn = today, force CurrentPage = totalPages, and require FinishedOn >= StartedOn.

  • Reject everything else — e.g. skipping WantToRead → Completed, or Completed → Reading.

The allowed/rejected transitions:

From \ To WantToRead Reading Completed
WantToRead ✓ (no-op) ✓ start ✗ skip
Reading ✓ (no-op) ✓ finish
Completed ✓ (no-op)

On failure, Apply returns RuleResult(false, "<message>"); the service turns that into a ValidationException.


6. Wire the service and data

The rules are pure; the service orchestrates them. Build, in order:

  • Core/Services/IReadingProgressService.csthe Day 2 integration surface. Keep it stable.
    Task<ReadingProgressDto?> GetForBookAsync(int bookId, CancellationToken ct);
    Task<ReadingProgressDto>  UpdateAsync(int bookId, UpdateReadingProgressRequest req, CancellationToken ct);
  • Core/Interfaces/IReadingProgressRepository.cs — the persistence port (keeps Core EF-free): GetForBookAsync, AddAsync, UpdateAsync, all taking a CancellationToken.
  • Core/Services/ReadingProgressService.cs — implements IReadingProgressService. Loads the Book for its TotalPages (KeyNotFoundException if the book is missing), loads the existing ReadingProgress or creates one, parses the status string, calls ReadingProgressRules.Apply(...) (throwing ValidationException on a rule break), persists via the repository, and maps to ReadingProgressDto. No EF here.
  • Data/Repositories/ReadingProgressRepository.cs — implements the port with async EF LINQ (FirstOrDefaultAsync by BookId; Add/Update + SaveChangesAsync(ct)). Threads ct, no raw SQL.

Then the persistence config and migration:

In BookTrackerDbContext: add DbSet<ReadingProgress> ReadingProgress, configure a Book 1—1
ReadingProgress with a UNIQUE INDEX on BookId, store Status via HasConversion<string>(), and
seed one ReadingProgress row.

Generate the migration from src/BookTracker:

dotnet ef migrations add AddReadingProgress \
  --project BookTracker.Data --startup-project BookTracker.Api

7. Add the endpoints (Api)

Add Endpoints/ReadingProgressEndpoints.csthin, async, returning DTOs (per the api-endpoints rule), and wire it in Program.cs:

Route Body Returns
GET /api/books/{id}/reading-progress 200 ReadingProgressDto | 404
PUT /api/books/{id}/reading-progress UpdateReadingProgressRequest 200 ReadingProgressDto | 400 (rule message) | 404

In Program.cs, register the service and repository and map the routes:

builder.Services.AddScoped<IReadingProgressService, ReadingProgressService>();
builder.Services.AddScoped<IReadingProgressRepository, ReadingProgressRepository>();
// ...
app.MapReadingProgressEndpoints();

The endpoint catches ValidationException400 with the rule message and KeyNotFoundException404. Apply the migration and smoke-test:

dotnet ef database update --project BookTracker.Data --startup-project BookTracker.Api
dotnet run --project BookTracker.Api
# in a second terminal
curl http://localhost:5255/api/books/1/reading-progress           # 200 or 404

curl -X PUT http://localhost:5255/api/books/1/reading-progress \
  -H "Content-Type: application/json" \
  -d '{"currentPage":42,"status":"Reading"}'                      # 200, StartedOn stamped

curl -X PUT http://localhost:5255/api/books/1/reading-progress \
  -H "Content-Type: application/json" \
  -d '{"currentPage":999999,"status":"Reading"}'                  # 400 with the rule message

8. Part D — Write the tests (test-writer subagent)

Use your C2 test-writer subagent to draft xUnit tests in BookTracker.Tests/ReadingProgressTests.cs covering every rule:

Use the test-writer subagent to write ReadingProgressTests.cs covering:
- a valid in-range page update succeeds
- CurrentPage > TotalPages is rejected
- negative CurrentPage is rejected
- WantToRead -> Reading sets StartedOn
- Reading -> Completed sets FinishedOn (>= StartedOn) and fills CurrentPage = TotalPages
- WantToRead -> Completed (skip) is rejected
- Completed -> Reading is rejected   <-- this is the regression test that guards the Part E bug
- GET returns 404 when no record exists

Run them green:

dotnet test

9. Part E — The debugging exercise (plant a bug)

Your feature is correct and green. Now break it on purpose and practice the debugging loop — the same loop you'll use on real bugs all day. Introduce a bug, watch the test go red, then drive Claude to the root cause and fix the source (not the symptom):

  1. Plant it. In ReadingProgressRules, make the guard allow Completed → Reading — e.g. drop the "Completed is terminal" check.
  2. Reproduce. Run dotnet test. The Completed → Reading regression test goes red. (Your C2 PostToolUse build hook also surfaces the break automatically as you edit.)
  3. Diagnose. Paste the failure into Claude Code and ask it to read the source and tests first, then explain the root cause before changing anything.

    This test is now failing: [paste]. Read ReadingProgressRules and the failing test, find the
    root cause, and fix the source so the state machine is correct again. Don't weaken the test.
  4. Fix and confirm. Restore the terminal-Completed guard, re-run dotnet test, and watch it go green again.

The point isn't the bug — it's the loop: introduce → failing test → root cause → source fix → green. Never fix a symptom in the test when the defect is in the source.


Checkpoint — you're done when:

  • specs/reading-progress.md is committed (6 sections, ≥3 prohibitions).

  • dotnet ef database update applies the AddReadingProgress migration cleanly.

  • dotnet build and dotnet test are green, including the Completed → Reading rejected test.

  • GET/PUT /api/books/{id}/reading-progress behave per the contract — 400 with the rule message, 404 for a missing book.

  • The debugging loop ran: bug introduced → reproduced with a failing test → root cause → source fixed → green.

  • No rule violations — DTOs at the boundary, async with CancellationToken, no raw SQL, rules in Core/Domain, data access in Data.

Tag this state checkpoint/c4-reading-progress. Day 1 is complete.


What's next

C5 (Day 2, Lab 1): you switch from Claude Code to the Anthropic C# SDK and start calling Claude from .NET code — Day 2 begins from this full Day 1 solution. The Reading Progress service you just built is the foundation: IReadingProgressService.UpdateAsync becomes the agent tool in C6, and GetForBookAsync becomes the MCP tool in C8. That's why the interface had to be stable.