Entry Pagination System¶
Overview¶
The entry pagination system is the student-facing playback surface of the LMS. Once a learner has enrolled in a course, every lesson, quiz, and assignment in that course is reached through the entry route /learn/course/\[slug\]/entry/\[entrySlug\]. This document specifies how entries are ordered, navigated, gated, completed, resumed, and previewed by admins.
It is the binding requirement for Phase 7 of implementation.md and complements the data model in Phase 2, the enrollment guard in Phase 6, and the quiz integration in Phase 8. Where this document and implementation.md disagree, the divergences listed in Divergences from implementation.md are authoritative and implementation.md should be updated to match.
Hierarchy & Ordering¶
The CMS hierarchy is fixed at three levels: Course → Module → Entry (see hierarchy-structure.md).
- Entries are ordered within their module by a CMS-authored
_orderfield. - Modules are ordered within their course by a CMS-authored
_orderfield. - For navigation purposes the system flattens the hierarchy: every entry in module 1 in
_orderascending, then every entry in module 2, and so on. This flattened sequence is the "linear" path through the course. - Slug uniqueness is scoped to
(course_slug, entry_slug). Two different courses may share the same entry slug. - All hierarchy data is fetched from edX CMS via the typed client in
src/lib/cms/client.tsand revalidated under ISR withrevalidate = 60.
Entry Types¶
The entry _type discriminator has three values:
| Type | Renderer | Completion mechanism |
|---|---|---|
lesson |
Portable Text body (RSC) | Explicit "Mark complete" click (see Completion Semantics) |
quiz |
QuizPlayer (client) |
Pass or maxAttempts exhausted (see quiz-system.md) |
assignment |
Stub — Phase 7+1 deferred | Manual mark-complete (placeholder behaviour) |
Quiz internals — question types, scoring, retry policy — are out of scope for this document; see quiz-system.md. The pagination system only consumes the completion signal that the quiz subsystem emits.
Navigation Model¶
Prev / Next Resolution¶
A pure helper, getEntryPagination(entries, currentEntrySlug), lives in src/lib/course/pagination.ts and returns { previous: Entry | null, next: Entry | null } against the flattened entry list. Module boundaries are not navigation boundaries — the next entry after the last entry of module N is the first entry of module N+1.
Boundaries¶
- The very first entry in a course:
previous === null. The "Previous" button renders witharia-disabled="true"(never the HTMLdisabledattribute — see Accessibility). - The very last entry in a course:
next === null. The "Next" button renders witharia-disabled="true". - A course with a single entry: both buttons disabled.
Sidebar¶
The course sidebar (CourseSidebar) groups entries visually by module but the prev/next buttons traverse modules transparently. Modules with children include a module overview entry (same slug as the module) as the first item in the flattened sequence; the sidebar module title links to that entry, and child entries list beneath the chevron (overview omitted from the nested list). Each entry row reflects one of three states: complete (checkmark), available (active), or locked (lock icon, aria-disabled, dimmed). Expandable module and folder headers use the same gate icons (overview entries use their own progress; overview-less modules use rollup over child entries). See Sequential Gating Policy for what locked means and Components & Test IDs for data-testid conventions.
Keyboard Shortcuts¶
The useEntryShortcuts client hook binds ← and → to prev/next navigation. The hook:
- Ignores keystrokes when focus is inside an
input,textarea,select, orcontenteditableelement. - Ignores keystrokes when the target button is
aria-disabled="true". - Announces the navigation via a screen-reader live region using a translation-keyed message.
Sequential Gating Policy¶
Default Rule¶
An entry is available to a given learner iff every entry that precedes it within the same module has entry_progress.completed_at IS NOT NULL for that learner. Cross-module gating is intentionally off in the default policy: jumping ahead to the first entry of module 3 does not require any module-1 or module-2 entries to be complete. The first entry of a module is always available once the learner is enrolled.
This default is appropriate for typical course structures where modules are loosely coupled. Stricter policies (per-course or per-module prerequisites) are out of scope for Phase 7 but the implementation must keep the policy logic centralised so a future override can be added without rippling.
Implementation Hook¶
Sequential gating is per course, not per tenant. The entry access seam loads isSequentialGatingEnabled(db, tenantId, courseSlug) from src/lib/course/features.ts (backed by course_settings and course-configuration.md) and computes gate states through resolveEntryAccess. When entries.sequentialGating is off, every entry is available subject to enrollment.
Locked-Entry UX¶
- Sidebar: locked entries are rendered, not hidden. They show a lock icon, dimmed styling,
aria-disabled="true", andtabindex="0"(focusable for screen-reader users). Activating a locked row (click, Enter, or Space) does not navigate; instead it surfaces asonnertoast whose copy is translation-keyed and explains the prerequisite ("Complete the previous lessons in this module first."). - Server-side: if the learner reaches a locked entry URL by deep link, hand-typed URL, or shared link, the entry page RSC calls
resolveEntryAccess(...)and redirects to the course landing page (/learn/course/\[slug\]?notice=locked). The landing page readsnotice=lockedand surfaces the same lock toast key. This is defence in depth — the sidebar and course-outline rows already prevent navigation in normal use.
Completion Semantics¶
Completion is the act of writing entry_progress.completed_at for a (user_id, tenant_id, course_slug, entry_slug) tuple. The write is idempotent — re-marking a completed entry is a no-op on completed_at but refreshes last_viewed_at.
Lessons¶
Lessons complete when the learner explicitly clicks "Mark complete" on the entry page. On a successful completion write, the learner is automatically advanced to the next entry in the flattened sequence; if there is no next entry, the learner returns to the course landing page. The system does not auto-complete lessons when learners navigate with the prev/next controls without clicking "Mark complete". Rationale: explicit completion remains recoverable (an accidental click can be undone with a follow-up "Mark incomplete" if that affordance ships), gives screen-reader users a clear focus target, and avoids confusion in courses where learners skim entries non-linearly.
Quizzes¶
A quiz entry is complete when either the learner passes (score ≥ pass threshold) or the learner exhausts quiz.maxAttempts without passing. Both states write entry_progress.completed_at. The full attempt history (pass and fail) is preserved separately in quiz_attempts and remains visible to the learner in review mode. Rationale: gating must not soft-lock a learner whose final permitted attempt fails on a non-passable quiz. The pass/fail outcome is preserved on quiz_attempts.passed for analytics and dashboard surfaces; gating only consumes entry_progress.completed_at.
Assignments¶
Assignments are a stub in Phase 7. Their completion mechanism is a manual "Mark complete" click identical to lessons. A real submission/grading flow is out of scope.
Persistence Contract¶
INSERT INTO entry_progress (user_id, tenant_id, course_slug, entry_slug, completed_at, last_viewed_at)
VALUES ($1, $2, $3, $4, NOW(), NOW())
ON CONFLICT (user_id, tenant_id, course_slug, entry_slug)
DO UPDATE SET last_viewed_at = NOW();
The unique index (user_id, tenant_id, course_slug, entry_slug) (defined in Phase 2 §5 of implementation.md) is the conflict target. All Kysely query helpers live in src/db/queries/entry-progress.ts and every query is scoped by both user_id and tenant_id — see .cursor/rules/080 - Data & Auth Integration.mdc.
Resume / Continue¶
Both /learn/course/\[slug\] and /learn/dashboard render a Continue CTA per enrolled course. The link target is computed server-side as follows:
- If the learner has never started the course: link to the first entry in the flattened sequence. Button copy:
entry.cta.start. - Else if there is at least one incomplete entry: link to the first incomplete entry in flattened order. Button copy:
entry.cta.continue. - Else (every entry complete): link to the entry stored in the learner's most recent
entry_progress.last_viewed_atrow for the course. Button copy:entry.cta.review.
last_viewed_at is updated whenever the entry RSC renders for an enrolled learner (see Server Actions). It is intentionally not stored in the browser — multi-device resume requires a server-side record.
Admin Preview Mode¶
course_admin, tenant_admin, and super_admin users are treated as preview users on entry surfaces. This mode:
- Bypasses enrollment checks on entry routes (admin roles can inspect course content without learner enrollments).
- Bypasses learner gating — every entry remains directly reachable.
- Is read-only — no preview-role write is persisted for learner progress:
markEntryViewedActionreturns success without writingentry_progress.markEntryCompleteActionreturns success without writingentry_progress.submitQuizAttemptActionreturnsFORBIDDENand writes noquiz_attemptsrow.
- Surfaces role-specific read-only hints in the UI (
quiz-preview-noticeandlesson-preview-notice).
Authorisation is enforced server-side through resolveEntryAccess(...). student sessions are always evaluated as learner mode.
Data Model¶
CREATE TABLE entry_progress (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
course_slug TEXT NOT NULL,
entry_slug TEXT NOT NULL,
completed_at TIMESTAMPTZ NULL,
last_viewed_at TIMESTAMPTZ NULL
);
CREATE UNIQUE INDEX entry_progress_unique_idx
ON entry_progress (user_id, tenant_id, course_slug, entry_slug);
The last_viewed_at column is part of the Phase 2 persistence schema in implementation.md. The corresponding migration follows the numbered tsx convention in src/db/migrations/ and is run by scripts/db-migrate.ts.
A row exists for any (user, course, entry) the learner has viewed at least once, even if they have not completed it. completed_at IS NULL distinguishes "viewed but not complete" from "complete".
Server Actions¶
All server actions live under src/app/learn/course/\[slug\]/entry/\[entrySlug\]/actions.ts, are marked 'use server', and validate every input with Zod.
markEntryComplete(courseSlug, entrySlug)¶
- Resolve session via
await auth(). Reject if unauthenticated. - For students, call
resolveEntryAccess(...)and reject when the current entry is locked. - For non-student preview roles, return
actionOk(undefined)without writing progress. - Upsert into
entry_progressper the Persistence Contract. revalidateTag('course:' + courseSlug)andrevalidateTag('progress:' + userId).
recordEntryView(courseSlug, entrySlug)¶
Called from a lightweight client tracker (EntryViewTracker) on entry mount. The server action updates last_viewed_at for enrolled learners and is a no-op for non-student preview roles. Failures are logged and swallowed — they must never break entry rendering.
submitQuizAttempt(...)¶
Owned by quiz-system.md. On either a passing submission or the exhaustion of maxAttempts, it calls into the same upsert helper used by markEntryComplete so the persistence and revalidation behaviour is identical.
Project theme¶
The course sidebar and entry shell inherit CMS project theme tokens (including mirrored --sidebar* variables). See course-theme.md.
Components & Test IDs¶
Per the AGENTS.md data-testid convention, every custom component and interactive sub-element exposes a stable selector for Playwright. Primitives from @open-learning-hub/ui are exempt (they already expose data-slot).
| Component | data-testid |
Notes |
|---|---|---|
EntryPage (RSC) |
entry-page |
Wraps body, resources, nav. |
EntryNav |
entry-nav |
Container for prev/next. |
EntryNav prev button |
entry-nav-prev |
aria-disabled at first entry. |
EntryNav next button |
entry-nav-next |
aria-disabled at last entry. |
EntryCompletionBadge |
entry-completion-badge |
Reflects completed_at. |
MarkCompleteButton |
mark-complete-button |
Lessons + assignments only. |
CourseSidebar |
course-sidebar |
Module-grouped list. |
| Module group | sidebar-module-<moduleSlug> |
Per-module <li> wrapper. |
| Module chevron | sidebar-module-toggle-<slug> |
Expand/collapse only. |
| Module overview link | sidebar-module-link-<slug> |
Links to overview entry. |
| Folder group | sidebar-folder-<moduleSlug>-<folderSlug> |
Nested folder <li>. |
| Folder chevron | sidebar-folder-toggle-<module>-<folder> |
Expand/collapse only. |
| Folder overview link | sidebar-folder-link-<module>-<folder> |
Links to folder overview entry. |
CourseSidebar row |
entry-row-<entrySlug> |
aria-disabled when locked. |
ContinueButton |
continue-button |
Used on course home and dashboard. |
| Course progress panel | course-progress-panel |
Enrolled course-page progress summary. |
| Course progress bar | course-progress-bar |
aria-labelledby by progress text. |
| Outline entry row | course-outline-entry-<entrySlug> |
Includes data-state + data-progress. |
| Outline quiz score | course-outline-entry-quiz-score-<entry> |
Latest quiz score badge on course page. |
| Lesson preview notice | lesson-preview-notice |
Non-student read-only hint near complete CTA. |
| Quiz preview notice | quiz-preview-notice |
Non-student read-only hint above quiz body. |
Each component above ships with a co-located *.test.tsx, per the 100% file-coverage rule in implementation.md Phase 11.
Internationalisation¶
Every user-visible string in this subsystem is rendered via a translation key — never hard-coded English. Keys live in messages/{en,es,fr,de,pt,zh}.json and all six files must be updated together (next-intl fails the build on a missing key — see AGENTS.md). Required keys:
entry.nav.previous,entry.nav.nextentry.completion.complete,entry.completion.incompleteentry.cta.start,entry.cta.continue,entry.cta.review,entry.cta.markCompleteentry.lock.toast,entry.lock.redirectNotice,entry.lock.lockedLabelentry.shortcut.announceNext,entry.shortcut.announcePreviousentry.preview.banner,entry.preview.leave
CMS-authored body content (lesson Portable Text, quiz questions, resource titles) is rendered as-is and is not translated by us — locale support for CMS content is the CMS's responsibility.
Accessibility¶
- Disabled-state nav buttons use
aria-disabled="true"rather than the HTMLdisabledattribute, so screen-reader users can still focus and discover them. Activation handlers checkaria-disabledand short-circuit. - Locked sidebar rows are focusable (
tabindex="0") and announce their state viaaria-disabledplus visually-hidden text ("Locked. Complete previous entries to unlock."). - After a successful "Mark complete" click, focus follows the automatic navigation target (the next entry page, or the course landing page at the course boundary).
- The
useEntryShortcutshook writes activations into a politearia-live="polite"region so screen-reader users hear the navigation. - All colour pairings in completion badges, lock icons, and the preview banner pass WCAG AA contrast in both light and dark themes (verified by the
@axe-core/playwrightsweep — seeimplementation.mdPhase 11).
Caching & Revalidation¶
- The entry RSC is statically generated with
revalidate = 60. CMS content (Portable Text body, resources) is fetched throughsrc/lib/cms/client.tsand tagged withcourse:<slug>andentry:<courseSlug>:<entrySlug>so admin-driven invalidations can be scoped. - Per-learner data (the completion badge state, sidebar lock states, the "Continue" target) is read from Kysely on every request — never cached, never tagged. This is per-request work, not ISR.
- Server actions revalidate
course:<slug>(so the sidebar and badges refresh) andprogress:<userId>(so the dashboard refreshes).
Testing Requirements¶
Unit (Vitest, happy-dom)¶
getEntryPagination: first-entry, last-entry, single-entry, single-module, cross-module-traversal cases.getEntryAccess: enrolled-not-gated, enrolled-gated, preview-mode, denied (non-enrolled student).- Resume target resolution: never-started, mid-course, fully-complete cases.
Component (Vitest)¶
Every component listed in Components & Test IDs has a co-located .test.tsx covering rendered states (complete / incomplete / locked / preview) and interaction handlers. The CI check that fails when a .tsx lacks a sibling .test.tsx (Phase 11 §1) enforces this.
E2E (Playwright)¶
- Linear progression: enrol → first entry → mark complete → next entry available → repeat to the last entry.
- Deep-link to a locked entry redirects to the course landing page and the redirect toast appears.
- Sidebar lock: clicking a locked row does not navigate and the lock toast appears.
- Quiz pass unlocks the next entry; quiz fail with
maxAttemptsexhausted also unlocks the next entry. - "Continue" CTA resumes at the first incomplete entry; for a fully-complete course it falls back to the last viewed entry.
- Admin preview roles:
course_admin/tenant_admin/super_admincan navigate entries without learner enrollment writes, and noentry_progress/quiz_attemptsrows are written from preview mode interactions. - Keyboard shortcuts:
←/→navigate; both are no-ops when focus is in atextarea; both are no-ops at the boundaries. - Theme + locale sweep: each scenario above renders in light and dark, and at least one scenario renders correctly in
esto prove the translation keys are wired.
Use DATA_DIR=./data/test and unique resource names per test (per AGENTS.md test convention).
Open Questions / Deferred¶
- Lesson auto-complete on navigation away: currently explicit-only (recommended). Revisit if learner-research finds explicit clicks confusing.
- Per-course gating overrides: the
gating.tshook is in place but no override mechanism is exposed. Defer until a real requirement surfaces. - Module-level prerequisite chains: out of scope.
- Time-spent / partial-progress tracking: out of scope.
entry_progressis binary (complete or not). - "Mark incomplete" affordance: not in Phase 7. Add when a real undo requirement surfaces.
Divergences from implementation.md¶
The following requirement in this document refines implementation.md and should be propagated back when that file is next updated (Phase 12):
- Admin preview mode for non-student roles. Not currently described in
implementation.md. This document specifies role-derived, read-only preview forcourse_admin,tenant_admin, andsuper_admin, enforced throughresolveEntryAccess(...)and action guards.
Related Documents¶
- implementation.md — Phase 2 (data model), Phase 6 (enrollment guard), Phase 7 (binding spec), Phase 8 (quiz integration), Phase 9 (dashboard "Continue").
- user-management.md — F6 (locked-entry redirect), F7 (admin gates).
- hierarchy-structure.md — Course / Module / Entry shape and ordering.
- quiz-system.md — quiz completion handoff.
- toggleable-navigation.md — sidebar interaction patterns.
- course-enrollment-system.md — enrollment status states consumed by
getEntryAccess. .cursor/rules/030 - Coding Best Practices.mdc,.cursor/rules/080 - Data & Auth Integration.mdc— server-only enforcement and multi-tenancy scoping rules.