Until now, signing in dropped everyone on the public /catalog — the same page an anonymous visitor sees. Fine for browsing, useless for a returning Student whose real question is narrower: which courses am I in the middle of, and where do I click to keep going? This slice answers exactly that, and nothing more.

A read model, not a new aggregate

The temptation with a “dashboard” is to invent a dashboard thing — a table, a denormalized progress cache, a nightly job. We built none of that. Every number on the screen is derived, on request, from data we already record: an Enrollment links a Student to a Course, and a Completion records that they finished a specific Lesson. Progress is just the aggregate of those Completions over the Course’s total Lessons.

So the new Enrollments.list_student_progress/1 is a read model: it lists the Student’s Enrollments and, for each, folds the existing progress_percentage/2 and completed_lesson_ids/1 into one map — percentage, lessons remaining, and the next incomplete Lesson. Zero new progress math. If the Completion logic is right (it is — that was 0030 Track Lesson Progress), the dashboard is right by construction.

flowchart TD
  S[Student] -->|has many| E[Enrollment]
  E -->|grants access to| C[Course]
  C -->|ordered| CH[Chapter]
  CH -->|ordered| L[Lesson]
  E -->|accumulates| CO[Completion]
  CO -.->|one per finished| L

  E --> ENTRY{{"dashboard entry (read model)"}}
  CO --> ENTRY
  L --> ENTRY
  ENTRY --> PCT["percentage = completed / total"]
  ENTRY --> REM["lessons remaining = total − completed"]
  ENTRY --> NEXT["UP NEXT = first Lesson with no Completion, in Chapter→Lesson order"]

What the Student sees

My learning — one card per enrolled Course, ordered in-progress first

One card per Enrollment. Each shows the Course title, a progress bar with its percentage, the lessons remaining (“3 of 4 lessons remaining”), and an UP NEXT label naming the first Lesson without a Completion. The primary Continue button deep-links straight to that Lesson; Course overview goes to the Course page. A returning Student is one click from where they stopped.

And when there’s nothing yet — a fresh account, or someone who only ever browsed — the list doesn’t render blank. It renders an invitation:

Empty state — a fresh account is pointed at the catalog, not shown a blank list

Three decisions worth their own paragraphs

“Lessons remaining,” not “time remaining.” The mockup hinted at “3h 04m left.” We don’t have it — Lessons carry no duration — and inventing one would be a lie rendered in monospace. “Chapters remaining” needs per-chapter completion counting we’d have to write. “Lessons remaining” is total − completed, both numbers we already have. We shipped the honest metric and deferred the rest.

Ordering encodes intent, not recency alone. Cards sort into three buckets — in-progress first, then not-started, then completed — and only within a bucket by most-recently-enrolled. A half-finished course is what you came back for; a finished one is a trophy, sorted to the bottom. This rule lives in the read model (a stable sort over the recency-ordered query), so the screen stays dumb and the ordering stays tested.

The login redirect now branches by role. signed_in_path was a hardcoded /catalog for everyone. It now asks one question — is this an Instructor? — and sends the admin to their Studio (/courses) and the Student to /dashboard. The dashboard is explicitly not for the Instructor; they have their own home.

Edge cases the read model has to get right

  • A Course unpublished after you enrolled still shows. An Enrollment is full, active access independent of the Course’s Draft/Published status — losing the card because the Instructor flipped a switch would be a bug, not a feature.
  • A 100%-complete Course keeps its card (sorted last), swaps UP NEXT for a completed state, and falls its primary action back to “Course overview” — there’s no next Lesson to resume.
  • A Course with zero Lessons reads 0% (0/0) with no UP NEXT, and “Continue” likewise falls back to the overview.

All three are in the screenshot above — the two Certified Scrum Master cards are precisely the Draft-with-lessons and the zero-Lesson cases, and Scrum Foundations is the 100% one in green.

What we deliberately left out

The design mockup is richer than this slice: a learning streak, “this week” study time, a Completed / Certificates section, Recommended next. Each depends on something that doesn’t exist yet — an activity/time-tracking domain, the Certificate aggregate (0070, still ahead), a recommendation engine. Building the dashboard around those gaps, rather than faking them, kept the slice vertical and shippable. They’ll arrive when their domains do.

Verifying it

Tests cover the read model (ordering, the Draft/100%/zero-Lesson cases, “only this Student’s Enrollments”) and the LiveView (rendering, the role-branched redirect). But “tests pass” and “works in the browser” are different claims. Running the real screen confirmed the second: a seeded Student with five Enrollments across every state rendered in the right order, the admin login landed on /courses, and the empty-state account got the catalog CTA — captured in the two screenshots above.


Acceptance criteria

  • Visit /dashboard ("My learning") after logging in — Student redirected here, Instructor to /courses
  • One card per enrolled Course: title, progress bar with percentage, and lessons remaining
  • UP NEXT names the first Lesson without a Completion, in Chapter→Lesson order
  • Continue deep-links to that next incomplete Lesson; Course overview links to the Course page
  • No Enrollments → an empty state with a CTA to the catalog, not a blank list
  • Header links between Catalog and My learning; catalog shows "My learning" when authenticated
  • List only the current Student's Enrollments — never another's, never un-enrolled Courses
  • Order: in-progress, then not-started (0%), then completed (100%); most-recently-enrolled first within each
  • Progress computed from existing Completions — no new progress math
  • A Draft/unpublished enrolled Course still shows its card
  • At 100%: sorted last, completed state instead of UP NEXT, primary action falls back to Course overview
  • Zero Lessons: 0% with no UP NEXT, Continue falls back to Course overview
  • Greet by Profile name with a count of courses in progress — no streak, no time-tracking

Key decisions

The dashboard is a read model over Completions — No new aggregate, table, or cache. list_student_progress/1 folds the existing progress functions into a per-Enrollment view computed on request. The screen owns no progress logic; if Completions are correct, so is the dashboard.

Lessons remaining over time remaining — We shipped the metric we can compute honestly (total − completed) rather than the richer one the mockup implied but the domain can’t support. No per-Lesson duration means no “X time left” — yet.

Ordering lives in the domain, the LiveView stays dumb — The three-bucket sort (in-progress → not-started → completed, recency within) is a stable sort in the read model, kept out of the template and under test.

Role-branched post-login redirectsigned_in_path now sends Instructors to /courses and Students to /dashboard. Easy to reverse, no trade-off worth an ADR — recorded here and in the item’s Conversation, not in docs/adr/.


Next up: PBI 0040 — Purchase a paid course (Paddle-triggered purchase-enrollment).