diff --git a/docs/ux-overhaul/production-route-migration.md b/docs/ux-overhaul/production-route-migration.md index 7cb8a89..3d1e75b 100644 --- a/docs/ux-overhaul/production-route-migration.md +++ b/docs/ux-overhaul/production-route-migration.md @@ -1,11 +1,17 @@ # Production Route Migration -Phase 1 preserves route compatibility and establishes canonical role destinations without deleting deep links. +Phase 1 preserves route compatibility and establishes canonical role destinations without deleting deep links. Phase 2 begins the student `/learn` migration while keeping legacy student deep links available. | Current route | Canonical route | Role | Redirect behavior | Guard behavior | Migration phase | Removal criteria | Deep-link risk | | --- | --- | --- | --- | --- | --- | --- | --- | | `/home` | `/home` for teacher/admin, transitional for other roles | teacher, instructor, admin, super_admin-compatible | No redirect | `HomeSessionGuard`; child guards unchanged | Phase 1 | Only after all role dashboards have route-level replacements and analytics show low usage | High | | `/learn` | `/learn` | student | Login routes students here after ready bootstrap | `HomeSessionGuard`; learner child routes unchanged | Phase 1 | None planned | Low | +| `/learn/products` | `/learn/products` visible as Courses, with course path links to `/learn/courses/:courseId` | student | No redirect | `HomeSessionGuard`; existing learner-product API still used | Phase 2 | Keep as transitional browse route until learner-product and course catalog surfaces are unified | Low | +| `/learn/courses/:courseId` | `/learn/courses/:courseId` | student | New canonical course overview; no redirect | `HomeSessionGuard`; course access remains enforced by backend endpoints and governed start behavior | Phase 2 | None planned; direct route should remain | Low | +| `/learn/paths` | `/learn/paths` | student | No redirect | `HomeSessionGuard`; existing programs API | Phase 2 partial | None until learning paths are redesigned | Low | +| `/learn/certificates` | `/learn/certificates` visible as Achievements | student | No redirect | `HomeSessionGuard`; existing certification APIs | Phase 2 partial | None until achievements are redesigned | Low | +| `/learn/resources` | `/learn/resources` | student | No redirect | `HomeSessionGuard`; no API | Phase 2 partial | Remove only if a supported resources surface is not planned | Low | +| `/learn/lesson/:id` | `/learn/lesson/:id` | student | No redirect; `:id` remains a student unit progress id | `HomeSessionGuard`; governed segment restore unchanged | Phase 2 | None planned | Low | | `/workspace` | `/workspace` | content_admin | No redirect | `HomeSessionGuard` plus existing role guards | Phase 1 | None planned | Medium | | `/workspace/learners` | `/workspace/learners` | org_admin | No redirect | Existing creator-role guard | Phase 1 | Replace only if organization home becomes a dedicated route | Medium | | `/workspace/commercial` | `/workspace/commercial` with visible label `Community` | admin/content_admin/org_admin/teacher/instructor via existing creator role set | No redirect | Existing creator-role guard | Phase 1 | Add alias and deprecation only after community surface is redesigned | Medium | @@ -15,6 +21,8 @@ Phase 1 preserves route compatibility and establishes canonical role destination | `/home/sections` | `/home/sections` | teacher, instructor, org_admin | No redirect | Existing `RoleGuard` section roles | Phase 1 | Class/cohort IA aliases are implemented and smoke tested | High | | `/home/courses` | `/home/courses` | teacher, instructor, student transitional | No redirect | Existing child route, no added guard | Phase 1 | Course library split is implemented and tested | Medium | | `/home/programs` | `/home/programs` | teacher, instructor, student transitional | No redirect | Existing child route, no added guard | Phase 1 | Learning path route aliases are implemented and tested | Medium | +| `/home/certifications` | `/learn/certificates` for student-origin actions | student transitional | No redirect | Existing child route, no added guard | Phase 2 partial | Existing links aliased and achievements page redesigned | Medium | +| `/home/lesson/:id` | `/learn/lesson/:id` for student-origin actions | student transitional, staff preview transitional | No redirect | Existing authenticated guard; governed segment restore unchanged | Phase 2 partial | Remove only after staff lesson preview route is explicit and student bookmarks have an alias | High | | `/home/me/preferences` | `/home/me/preferences` | all authenticated roles | No redirect | Existing authenticated shell | Phase 1 | Dedicated settings route aliases are implemented | Medium | ## Canonical Login Destinations @@ -32,3 +40,6 @@ Phase 1 preserves route compatibility and establishes canonical role destination - Browser bookmarks are preserved; no broad route deletion was performed. - Unauthorized routes remain protected by existing route guards. Hidden navigation is not treated as authorization. - `/workspace/commercial` remains the technical path in this phase, but visible navigation now uses `Community`. +- Phase 2 updates student-origin actions from the legacy student dashboard to navigate into `/learn/**` while preserving `/home/**` deep links. +- Phase 2 adds `/learn/courses/:courseId` as the canonical student course overview and keeps `/learn/products` as a transitional browse route. +- No dedicated unit route was added because existing APIs do not expose unit-level student progress or direct-link authorization. diff --git a/docs/ux-overhaul/shared-component-status.md b/docs/ux-overhaul/shared-component-status.md index 91af75b..ac85d09 100644 --- a/docs/ux-overhaul/shared-component-status.md +++ b/docs/ux-overhaul/shared-component-status.md @@ -7,11 +7,16 @@ | Shell navigation service | `frontend/src/app/services/shell-navigation.service.ts` | Production active | Centralizes role-to-navigation and canonical route logic using existing permissions. | | Confirmation dialog | `frontend/src/app/components/echo-confirmation-dialog/` | Available, not broadly connected | Supports default, destructive, and publish variants; focus trap and Escape behavior covered by unit tests. | | Skeleton | `frontend/src/app/components/echo-skeleton/` | Available | Tokenized primitive for later page migrations. | +| Student curriculum | `frontend/src/app/components/student-curriculum/student-curriculum.component.ts` | Production active for student course overview | Renders unit hierarchy, lesson state labels, locked/current/complete states, and mobile layout. | ## Migrated Or Extended | Component | Path | Status | Notes | | --- | --- | --- | --- | +| Button | `frontend/src/app/components/echo-button/echo-button.component.ts` | Extended in Phase 2 | Adds `secondary` color support and optional `ariaLabel` for accessible student action labels. | +| Confirmation dialog | `frontend/src/app/components/echo-confirmation-dialog/` | Connected to assessment submission | Used as a neutral consequential confirmation before assessment attempts. | +| Lesson viewer | `frontend/src/app/shared/lesson-viewer.component.*` | Migrated for local activity states | Adds activity instruction, quiz validation alert, and current step state text without adding scoring behavior. | +| Certifications page | `frontend/src/app/pages/certifications/` | Migrated for student achievements | Uses shared loading, empty, and error states and learner-facing certificate language. | | Authenticated shell | `frontend/src/app/pages/home/` | Production active | Adds skip link, role-aware mobile navigation, overlay, scroll lock, and focus return. | | Header | `frontend/src/app/components/echo-header/` | Production active | Uses role-aware shell titles and shared state panels for organization states. | | Sidebar | `frontend/src/app/components/echo-sidebar/` | Production active | Uses role-aware navigation sections and tokenized active/focus states. | @@ -24,6 +29,8 @@ - Form controls, tables, filter controls, page-specific buttons, dashboard cards, and internal page layouts are still mixed Tailwind/SCSS implementations. - Existing Storybook/design-lab artifacts remain references; production consumers were not migrated wholesale. - Destructive actions still need page-by-page adoption of `EchoConfirmationDialogComponent`. +- Form controls, tables, filter controls, page-specific buttons, dashboard cards, and internal page layouts are still mixed Tailwind/SCSS implementations. +- Activity controls delegated to canvas, map, and storybook components still need full accessibility and responsive QA. ## Required Future Adoption diff --git a/docs/ux-overhaul/student-accessibility-review.md b/docs/ux-overhaul/student-accessibility-review.md new file mode 100644 index 0000000..5720768 --- /dev/null +++ b/docs/ux-overhaul/student-accessibility-review.md @@ -0,0 +1,31 @@ +# Student Accessibility Review + +Date: 2026-07-11 + +## Completed + +- `/learn` has one `h1`: "Continue your learning". +- Next learning appears first in DOM order after the page hero. +- Course progress bars include `role="progressbar"`, `aria-valuenow`, and text percentages. +- Student course states use text labels such as `Ready`, `In progress`, and `Complete`; status is not color-only. +- Loading, empty, and error states reuse shared `EchoLoadingStateComponent` and `EchoStatePanelComponent`. +- Lesson save state uses `role="status"` and save failure uses `role="alert"`. +- The lesson runtime return button has an accessible name: "Return to the course overview". +- Migrated controls use 44px-friendly button/link sizing and visible focus styles. +- `/learn/courses/:courseId` uses one `h1`, semantic ordered lists for unit and lesson hierarchy, text state labels, disabled lesson controls for locked/unavailable lessons, and accessible progress bars. +- Lesson runtime now exposes course, unit, and lesson-position context when the existing student-course data can resolve it. +- Quiz activity validation uses `role="alert"` and does not rely on color alone. +- Assessment submission uses the shared confirmation dialog with focus trap/Escape behavior supplied by `EchoConfirmationDialogComponent`. +- Certificate loading, empty, and error states use shared status/alert semantics. + +## Risks + +- Full keyboard walkthrough was not completed against a running seeded backend in this pass. +- Full automated contrast tooling was not run. +- Activity controls that delegate to canvas/map/storybook components still need device and assistive-technology QA in a later pass. + +## Deferred Checks + +- Keyboard-only seeded student journey through login, `/learn`, lesson, activity, completion, and return. +- Screen-reader verification for lesson activity controls and assessment result states. +- 200% zoom verification with real seeded lesson content. diff --git a/docs/ux-overhaul/student-activity-type-matrix.md b/docs/ux-overhaul/student-activity-type-matrix.md new file mode 100644 index 0000000..1a8b94b --- /dev/null +++ b/docs/ux-overhaul/student-activity-type-matrix.md @@ -0,0 +1,31 @@ +# Student Activity Type Matrix + +Date: 2026-07-11 + +Activity rendering remains in `frontend/src/app/shared/lesson-viewer.component.*`. Formal scoring remains in assessment routes and APIs. + +| Type identifier | Renderer/component | API interaction | Submission model | Feedback model | States implemented | Accessibility risks | Responsive risks | +| --- | --- | --- | --- | --- | --- | --- | --- | +| `video` | Native `video` or YouTube `iframe` | None in viewer | None | Browser media controls | In progress, ready to continue | Captions depend on media source | Wide media at mobile widths | +| `image` | Native `img` | None | None | None | In progress, ready to continue | Alt uses activity title | Large images need viewport checks | +| `coloring` | `ColoringCanvasComponent` | None in viewer | Local canvas interaction | None | In progress, ready to continue | Canvas keyboard alternatives remain a future risk | Canvas controls need mobile QA | +| `interactive_map` | `AfricaMapExplorerComponent` | None in viewer | Local interaction | Component-specific | In progress, ready to continue | Must remain keyboard operable | Map density at 390px needs visual QA | +| `storybook` | `StorybookViewerComponent` | Static storybook assets | None | None | In progress, ready to continue | Image alt/captions depend on pages | Image fit needs visual QA | +| `audio` | Native `audio` | None | None | Browser media controls | In progress, ready to continue | Captions/transcripts depend on content | Native controls vary by browser | +| `story` | Text block | None | None | None | In progress, ready to continue | Long text readability | Good after wrapping | +| `reflection` | Textarea | None | Local draft only | None | In progress, ready to continue | Draft is not authoritative progress | Mobile keyboard overlap needs visual QA | +| `checkpoint` | Text block | None | None | None | In progress, ready to continue | None beyond reading level | Good after wrapping | +| `quiz` | Radio options or fallback text input | None in viewer | Local selection before lesson completion | No correctness shown | In progress, validation-needed, ready to continue | Must not imply scored submission | Long options wrap on mobile | +| fallback | Text block | None | None | None | In progress, ready to continue | Unknown content quality | Needs visual QA per content | + +## Implemented Interaction States + +- Not started: future lesson steps in the step list. +- In progress: current activity step before required local interaction is satisfied. +- Validation-needed: quiz activity when Next is pressed without a selected answer. +- Ready to continue: current step can move forward. +- Completed: prior steps in the step list. + +## Not Implemented Here + +Submitted, correct, incorrect, partially correct, retry available, retry unavailable, and awaiting feedback belong to formal assessment behavior because no lesson activity submission endpoint exists. diff --git a/docs/ux-overhaul/student-assessment-behavior.md b/docs/ux-overhaul/student-assessment-behavior.md new file mode 100644 index 0000000..78d894f --- /dev/null +++ b/docs/ux-overhaul/student-assessment-behavior.md @@ -0,0 +1,37 @@ +# Student Assessment Behavior + +Date: 2026-07-11 + +## Current API Behavior + +- Assessments load through `ProgramsService.getAssessment(id)` -> `GET /api/assessments/{assessment_id}`. +- Assessment attempts submit through `ProgramsService.submitAssessment(id, answers)` -> `POST /api/assessments/{assessment_id}/attempts`. +- Availability is governed by `is_available_for_learner`, `learner_delivery_state`, and `learner_delivery_detail`. +- The frontend requires every question to have an answer before submission. +- Submission returns score, max score, percentage, passed, submitted timestamp, and per-answer correctness/points. +- If an assessment belongs to a program, existing certification evaluation is refreshed with `GET /api/certifications?program_id=...` and `POST /api/certifications/{id}/evaluate`. + +## Implemented UX Rules + +- Unavailable assessments hide the form and show a shared unavailable/blocked state. +- Learners see answer count before submission. +- Final submission uses `EchoConfirmationDialogComponent`. +- The confirmation explains that answers will be submitted for scoring and that this screen does not expose editing after submission. +- Submission loading uses the shared loading state and sets `aria-busy` on the form. +- Submission failure preserves answers, keeps the confirmation dialog open, and shows the failure message. +- Results use learner-centered language: `Passed`, `Needs more practice`, `Confirmed understanding`, and `Review this idea`. +- The page links to `/learn/paths` and `/learn/certificates`; legacy `/home` links were removed from visible student copy. + +## Not Changed + +- No grading policy changed. +- No retry policy changed. +- Correct answers are not newly revealed. +- No new assessment sessions or incremental save behavior were introduced. +- No backend endpoints were added. + +## Known Gaps + +- The API does not currently expose whether retry is available after a failed attempt. +- Teacher-review-required state is not clearly surfaced beyond generic availability fields. +- Question-by-question review is limited to the result data currently returned by the backend. diff --git a/docs/ux-overhaul/student-course-data-contract.md b/docs/ux-overhaul/student-course-data-contract.md new file mode 100644 index 0000000..a96e6e0 --- /dev/null +++ b/docs/ux-overhaul/student-course-data-contract.md @@ -0,0 +1,38 @@ +# Student Course Data Contract + +Date: 2026-07-11 + +## Summary + +The completed student Phase 2 course overview composes existing frontend services and backend endpoints. No backend endpoint, database schema, role model, or progress rule was changed. + +| Field | Frontend source | Backend endpoint/schema | Returned | Derived | Optional | Safe to display | Notes | +| --- | --- | --- | --- | --- | --- | --- | --- | +| Course identifier | `Course.id`, `StudentCourseWithDetails.course_id` | `GET /api/courses/{course_id}` -> `CourseResponse.id`; `GET /api/student-courses` -> `StudentCourseWithDetails.course_id` | Yes | No | No | Yes | Used by canonical `/learn/courses/:courseId`. | +| Course title | `Course.title` | `CourseResponse.title` | Yes | No | No | Yes | Primary page title. | +| Course description | `Course.description` | `CourseResponse.description` | Yes | No | Yes in frontend model | Yes | Hidden if missing; no generated objectives. | +| Course image | `CourseDraft.imageUrl`, legacy card fields | Not included in `CourseResponse` | No | No | Yes | Not used | Existing API gap; overview uses content-first layout. | +| Enrollment/access state | `StudentCourseWithDetails` match by `course_id` | `GET /api/student-courses` | Yes for enrolled courses | No | Yes | Yes | Not enrolled courses are startable only through existing enroll flow. | +| Start state | `startCourse()` response | `POST /api/start-course` -> `SegmentResponse` | Yes | No | Yes | Yes | Backend requires enrollment and governs availability. | +| Completion state | `StudentCourseWithDetails.status` | `StudentCourseWithDetails.status` | Yes | No | Yes | Yes | Completed courses show review-only action. | +| Unit list/order | `course.units[].order` | `CourseResponse.units` / `UnitResponse.order` | Yes | Sorted client-side by returned order | Yes | Yes | No separate unit progress endpoint is used for students. | +| Lesson list/order | `unit.lessons[].order` | `LessonResponse.order` | Yes | Sorted client-side by returned order | Yes | Yes | Frontend `Lesson` model now includes `order?: number`. | +| Locked/unlocked state | Current `SegmentResponse` | `GET /api/progress/segment` | Only current segment state | Partially | Yes | Yes with caveats | Only current governed lesson is openable; other lessons are labeled locked/unavailable without bypassing governance. | +| Current lesson | `SegmentResponse.lesson_id` | `SegmentResponse.lesson_id` | Yes | No | Yes | Yes | Used for current lesson state and course runtime context. | +| Current unit | Course hierarchy containing current `lesson_id` | `CourseResponse.units[].lessons[]` | No direct field | Yes | Yes | Yes | Derived only to display location, not to authorize navigation. | +| Course progress | `CoursesService.getCourseProgress()` | Composes `GET /api/progress/segment` | No direct percentage in backend response | Yes | Yes | Yes | Existing frontend behavior preserved. | +| Unit progress | Current unit marker plus completed-course status | No student unit progress summary endpoint | No | Limited | Yes | Yes with neutral labels | Labels are "Current unit", "Included in course", or completed-course counts. | +| Lesson progress | Current segment only | `SegmentResponse.status`, `delivery_state` | Partial | Limited | Yes | Yes with neutral labels | No per-lesson completion matrix is available. | +| Assignment context | Not currently consumed by student course overview | Assignment APIs exist separately | Not in student course response | No | Yes | Not shown | Avoids fabricated assignment behavior. | +| Program/path context | `ProgramsService` surfaces paths separately | `/api/programs` | Not in course response | No | Yes | Not shown on course overview | Future phase can connect program context if API supports it. | +| Badge outcome | Badge/cert preview on `/learn`; certificates page | Badge and certification APIs | Partial | No | Yes | Yes | Course overview links to Achievements without promising a specific badge. | +| Certification outcome | `ProgramsService.getMyCertifications()` | `/api/students/me/certifications` | Yes for earned certs | No | Yes | Yes | No download/print action is fabricated. | +| Prerequisites | None found in student API | N/A | No | No | Yes | Not shown | Backend gap if future UX requires prerequisite display. | +| Required vs optional lessons | Not returned in course hierarchy | N/A | No | No | Yes | Not shown | Avoided to prevent unsupported claims. | + +## Known Gaps + +- `CourseResponse` does not return a course image. +- Student APIs do not return a complete per-lesson state matrix for locked, completed, available, retry, or required/optional status. +- Unit progress is not returned as a student-facing summary. +- Program/path context is separate from the course response and is not joined into the overview in this phase. diff --git a/docs/ux-overhaul/student-course-navigation.md b/docs/ux-overhaul/student-course-navigation.md new file mode 100644 index 0000000..054bbc3 --- /dev/null +++ b/docs/ux-overhaul/student-course-navigation.md @@ -0,0 +1,37 @@ +# Student Course Navigation + +Date: 2026-07-11 + +## Decision + +The canonical student course overview route is: + +`/learn/courses/:courseId` + +A dedicated unit route was not added in this phase. The course overview renders unit sections inline through `StudentCurriculumComponent`. + +## Rationale + +- The current backend supports `GET /api/courses/{course_id}` with ordered units and lessons. +- The current student progress API exposes the current governed segment, not independent unit-level navigation, per-unit completion, or unit deep-link authorization. +- Adding `/learn/courses/:courseId/units/:unitId` now would duplicate the course hierarchy without a stronger backend contract for unit progress or availability. +- Inline unit sections preserve browser back behavior, keep the primary action clear, and avoid implying unsupported direct lesson navigation. + +## Navigation Rules + +- `/learn` remains the student canonical home. +- `/learn/products` remains a transitional course browsing page backed by the learner-product API. +- `/learn/courses/:courseId` is the canonical course path and can be deep linked. +- `/learn/lesson/:id` remains the governed runtime route where `:id` is a student unit progress id, not a course or lesson id. +- Student lesson rows do not navigate directly unless they represent the current governed segment. +- Non-current lessons communicate their state as included/locked/unavailable using text, not color alone. + +## Future Unit Route Criteria + +Add `/learn/courses/:courseId/units/:unitId` only after the API can safely provide: + +- student unit progress summary, +- current lesson for that unit, +- locked/unlocked lesson state per lesson, +- direct-link guard semantics for inaccessible units, +- back/next behavior that does not bypass `startCourse()`. diff --git a/docs/ux-overhaul/student-production-audit.md b/docs/ux-overhaul/student-production-audit.md new file mode 100644 index 0000000..0755fa6 --- /dev/null +++ b/docs/ux-overhaul/student-production-audit.md @@ -0,0 +1,104 @@ +# Student Production Audit + +Date: 2026-07-11 + +Scope: Phase 2 student Learn experience for `overhaul-role-based-ui-ux-experience`. This audit verifies the current student-facing implementation before production changes. + +## Student Routes + +| Route | Component | Current role/access behavior | API dependencies | Notes | +| --- | --- | --- | --- | --- | +| `/learn` | `HomeComponent` > `UserDashboardComponent` > `LearnerPortalComponent` | Authenticated through `HomeSessionGuard`; canonical student landing from `ShellNavigationService`. | `/api/student-courses`, `/api/courses`, `/api/analytics/student-progress`, `/api/students/{id}/badges`, `/api/programs`, `/api/students/me/certifications`, `/api/learner-portal/products`, `/api/start-course`, `/api/enroll`. | Was product-wrapper oriented. Needs learner-first continuation, active learning, available learning, progress, and achievements. | +| `/learn/products` | `LearnerProductsComponent` | Authenticated through `HomeSessionGuard`; no additional role guard. | `/api/learner-portal/products`, `/api/start-course`, `/api/enroll`. | Course-backed V2 product list. Needs learner-facing labels and error/empty states. | +| `/learn/paths` | `ProgramsComponent` | Authenticated through `HomeSessionGuard`; no additional role guard. | `/api/programs`, `/api/programs/{id}/enroll`. | Existing learning path surface remains available. | +| `/learn/certificates` | `CertificationsComponent` | Authenticated through `HomeSessionGuard`; no additional role guard. | `/api/certifications`, `/api/students/me/certifications`, `/api/certifications/{id}/evaluate`. | Existing certifications surface remains available. | +| `/learn/resources` | `LearnerResourcesComponent` | Authenticated through `HomeSessionGuard`; no additional role guard. | None. | Empty resource state only. | +| `/learn/lesson/:id` | `LessonViewComponent` | Authenticated through `HomeSessionGuard`; uses governed segment restore. | `/api/progress/segment`, `/api/lessons/{id}`, `/api/progress/segment/complete`, `/api/student-courses`. | Reuses current lesson runtime and interrupted-session recovery. Needs canonical `/learn` exit behavior and save/completion state copy. | +| `/home` | `UserDashboardComponent` > `EchoedRoleSelectorComponent` > `StudentViewComponent` for student role | Authenticated through `HomeSessionGuard`; transitional route. | `/api/student-courses`, `/api/courses`, `/api/analytics/student-progress`, `/api/students/{id}/badges`, `/api/programs`, `/api/students/me/certifications`, `/api/start-course`, `/api/progress/segment/complete`. | Legacy student dashboard remains reachable. It has useful governed continuation behavior but routes to `/home/lesson/:id`. | +| `/home/courses` | `AvailableCoursesComponent` | Authenticated through `HomeSessionGuard`; shared staff/student route. | `/api/courses`, `/api/enroll`. | Legacy course catalog remains a compatibility route. | +| `/home/programs` | `ProgramsComponent` | Authenticated through `HomeSessionGuard`. | `/api/programs`, `/api/programs/{id}/enroll`. | Legacy learning paths route. | +| `/home/certifications` | `CertificationsComponent` | Authenticated through `HomeSessionGuard`. | `/api/certifications`, `/api/students/me/certifications`, `/api/certifications/{id}/evaluate`. | Legacy certifications route. | +| `/home/lesson/:id` | `LessonViewComponent` | Authenticated through `HomeSessionGuard`; governed segment restore. | `/api/progress/segment`, `/api/lessons/{id}`, `/api/progress/segment/complete`, `/api/student-courses`. | Legacy lesson deep links remain available. | + +## Components and Current Behavior + +- `frontend/src/app/pages/learner-portal/learner-portal.component.ts` + - Current `/learn` home. + - Uses `V2PlatformService.getLearnerProducts()`, `ProgramsService.getPrograms()`, `ProgramsService.getMyCertifications()`, `CoursesService.startCourse()`, and `CoursesService.enrollInCourse()`. + - Existing copy exposes "products" as the primary learner concept and does not use `/api/student-courses` for continuation priority. + +- `frontend/src/app/pages/learner-portal/learner-products.component.ts` + - Lists enrolled and available V2 learner products. + - Uses `CoursesService.startCourse()` and `CoursesService.enrollInCourse()` for course-backed items. + - Current labels include product wrapper/governance wording that is clearer for staff than for K-5 learners. + +- `frontend/src/app/pages/user-dashboard/student-view/student-view.component.*` + - Legacy student dashboard under `/home`. + - Uses student-course, analytics, badge, program, and certification APIs. + - Implements continuation selection from current enrolled courses and runtime support guidance. + - Still includes dashboard framing, demo timeline copy, and `/home` navigation targets. + +- `frontend/src/app/pages/lesson-view.component.*` + - Shared lesson runtime for `/home/lesson/:id`, `/workspace/lesson/:id`, and `/learn/lesson/:id`. + - Restores governed segment with `CoursesService.getCurrentSegment()`. + - On 404 stale segment, recovers through `CoursesService.getStudentCourses()` and navigates to the active `unit_progress_id`. + - Marks completion only after `CoursesService.markSegmentCompleted()` succeeds. + +- `frontend/src/app/shared/lesson-viewer.component.*` + - Shared learner lesson rendering. + - Hides educator-only notes in learner mode and emits `segmentCompleted` from the final learner action. + - Existing tests cover learner prompt visibility, quiz labels, step sequence, and progress display. + +- `frontend/src/app/pages/assessment-detail/assessment-detail.component.*` + - Existing student assessment route under `/home/assessments/:id`. + - Uses `ProgramsService.getAssessment()` and `ProgramsService.submitAssessment()`. + - Preserves answers on submit failure and blocks unavailable learner delivery. + +## Services and API Contracts + +- `CoursesService.getStudentCourses()` -> `GET /api/student-courses` + - Source for enrolled/current courses, continuation guidance, status, progress, and `unit_progress_id`. +- `CoursesService.getCourses()` -> `GET /api/courses` + - Source for currently visible course catalog. +- `CoursesService.startCourse()` -> `POST /api/start-course` + - Authoritative start/resume operation. UI must navigate only when `delivery_state === 'governed_available'` and `unit_progress_id` is present. +- `CoursesService.getCurrentSegment()` -> `GET /api/progress/segment?student_unit_id=...` + - Authoritative lesson-session restore. +- `CoursesService.markSegmentCompleted()` -> `POST /api/progress/segment/complete` + - Authoritative completion/advance operation. +- `CoursesService.getLessonById()` -> `GET /api/lessons/{id}` + - Authoritative lesson payload. +- `AnalyticsService.getStudentProgress()` -> `GET /api/analytics/student-progress` + - Learner progress metrics and badge progress. +- `BadgesService.getStudentBadges()` -> `GET /api/students/{id}/badges` + - Earned badge display. +- `ProgramsService.getPrograms()` -> `GET /api/programs` + - Structured learning paths. +- `ProgramsService.getMyCertifications()` -> `GET /api/students/me/certifications` + - Earned certification display. +- `V2PlatformService.getLearnerProducts()` -> `GET /api/learner-portal/products` + - Course/program access wrappers; useful for available learning but should not replace the course runtime model. + +## Testing Baseline + +- `frontend/src/app/pages/user-dashboard/student-view/student-view.component.spec.ts` covers continuation, loading/error/empty states, support guidance, badge/program/certification surfaces, and no staff publish-readiness UI. +- `frontend/src/app/pages/lesson-view.component.spec.ts` covers governed unavailable states, loading, stale segment recovery, demo lesson selection, and exit control label. +- `frontend/src/app/shared/lesson-viewer.component.spec.ts` covers learner-safe lesson content rendering and quiz controls. +- `frontend/tests/demo/student-flagship-smoke.spec.ts` signs in as a seeded student and opens the governed lesson path from `/learn`. + +## UX Problems Verified + +1. `/learn` used "products" as the dominant learner concept instead of active learning and next lesson. +2. Continuation on `/learn` did not prioritize `/api/student-courses`; it relied on V2 learner products first. +3. Legacy student flow under `/home` had stronger continuation/progress behavior but sent students to `/home/lesson/:id`. +4. Available learning copy exposed wrapper/runtime details rather than learner-centered course language. +5. The lesson runtime exit action returned to `/home`, even when entered from canonical `/learn`. +6. Completion dialog copy said "dashboard" rather than "Learn". +7. No `/learn` unit/course overview route currently exists beyond product and course cards; this phase can improve hierarchy in cards without changing backend contracts. +8. Student assignment surfaces are not exposed as a distinct API-backed `/learn` route; assigned learning is currently represented by enrolled/current courses. +9. Resource page is an empty placeholder; it must not imply unsupported downloadable resources are ready. +10. Legacy `/home` routes remain necessary during migration and should not be deleted in this phase. + +## Backend Change Assessment + +No backend changes are required for this phase. The existing APIs provide enough data for a learner-first `/learn` home, course cards, progress summary, certification/badge states, and governed lesson recovery. A dedicated student assignment endpoint could improve future IA but is not required to preserve current behavior. diff --git a/docs/ux-overhaul/student-production-implementation.md b/docs/ux-overhaul/student-production-implementation.md new file mode 100644 index 0000000..1bce071 --- /dev/null +++ b/docs/ux-overhaul/student-production-implementation.md @@ -0,0 +1,81 @@ +# Student Production Implementation + +Date: 2026-07-11 + +Status: Student Learn production phase complete with browser smoke execution blocked by local infrastructure. The full EchoEd UI/UX overhaul is not complete. + +## Implemented + +- Rebuilt `/learn` in `frontend/src/app/pages/learner-portal/learner-portal.component.ts` as the canonical student learning home. +- Prioritized a next-learning card from `CoursesService.getStudentCourses()` before available learning. +- Preserved governed runtime behavior by continuing to call `CoursesService.startCourse()` and navigating only when the backend returns `delivery_state: governed_available` plus `unit_progress_id`. +- Added current-course cards with progress text and accessible progress bars. +- Added available learning cards from existing `/api/courses` and `/api/learner-portal/products` data without changing backend contracts. +- Added progress summary from `AnalyticsService.getStudentProgress()`. +- Added badge and certificate preview using `BadgesService.getStudentBadges()` and `ProgramsService.getMyCertifications()`. +- Reframed `/learn/products` visible copy as student courses and available learning while preserving the existing learner-product API. +- Reframed `/learn/resources` as an honest empty state without promising unsupported downloads. +- Added canonical `/learn/courses/:courseId` in `frontend/src/app/pages/learner-portal/student-course-overview.component.ts`. +- Added reusable `StudentCurriculumComponent` for unit sections, lesson rows, current/locked/completed labels, and mobile-friendly hierarchy. +- Added course path links from `/learn` and `/learn/products` without changing the learner-product API. +- Kept direct lesson access governed through `CoursesService.startCourse()` and `GET /api/progress/segment`; non-current lessons do not bypass backend availability. +- Added course and unit context to `LessonViewComponent` by composing the current segment with `GET /api/student-courses`. +- Standardized local lesson activity states in `LessonViewerComponent`, including quiz validation before advancing. +- Added final assessment submission confirmation with `EchoConfirmationDialogComponent`. +- Reframed assessment result labels for learners while preserving score and answer result data from the backend. +- Refined `/learn/certificates` as an achievements/certificates view using shared loading, empty, and error states. +- Updated `LessonViewComponent` so student completion shows a saving state, save failure message, and canonical return to `/learn`. +- Updated legacy `StudentViewComponent` actions to route students toward `/learn/products`, `/learn/paths`, `/learn/certificates`, and `/learn/lesson/:id` while preserving legacy `/home` deep links. +- Extended `EchoButtonComponent` with `secondary` and `ariaLabel` support for migrated student controls. + +## APIs Used + +- `GET /api/student-courses` +- `GET /api/courses` +- `POST /api/enroll` +- `POST /api/start-course` +- `GET /api/progress/segment` +- `POST /api/progress/segment/complete` +- `GET /api/lessons/{id}` +- `GET /api/analytics/student-progress` +- `GET /api/students/{id}/badges` +- `GET /api/programs` +- `GET /api/students/me/certifications` +- `GET /api/learner-portal/products` +- `GET /api/courses/{course_id}` +- `GET /api/assessments/{assessment_id}` +- `POST /api/assessments/{assessment_id}/attempts` +- `GET /api/certifications` +- `POST /api/certifications/{certification_id}/evaluate` + +## Backend Changes + +None. Existing authentication, roles, progress, session, enrollment, badge, certification, and learner-product contracts are preserved. + +## Tests Added Or Updated + +- Added `frontend/src/app/pages/learner-portal/learner-portal.component.spec.ts`. +- Added `frontend/src/app/pages/learner-portal/student-course-overview.component.spec.ts`. +- Added `frontend/src/app/pages/certifications/certifications.component.spec.ts`. +- Added `frontend/src/app/components/student-curriculum/student-curriculum.component.ts`. +- Updated `frontend/src/app/shared/lesson-viewer.component.spec.ts` for activity validation states. +- Updated `frontend/src/app/pages/assessment-detail/assessment-detail.component.spec.ts` for final confirmation and failure behavior. +- Updated `frontend/src/app/pages/lesson-view.component.spec.ts` for Learn return labels and save failure behavior. +- Updated `frontend/src/app/pages/user-dashboard/student-view/student-view.component.spec.ts` for canonical student catalog routing. +- Updated demo Playwright expectations in `frontend/tests/demo/support/demo-smoke.ts` and `frontend/tests/demo/student-flagship-smoke.spec.ts`. + +## Verification + +- `cmd /c npm test -- --watch=false --browsers=ChromeHeadless`: 228 success. +- `cmd /c npm run build`: passed with existing Sass mixed-declaration deprecation warnings in `frontend/src/styles/_typography.scss`. +- `cmd /c .\node_modules\.bin\playwright.cmd test tests/demo/student-flagship-smoke.spec.ts --list`: 1 test discovered. +- `cmd /c .\node_modules\.bin\playwright.cmd test tests/demo/student-flagship-smoke.spec.ts`: blocked/fails at `http://127.0.0.1:4200/login` with `ERR_CONNECTION_REFUSED` because the frontend dev server is not running. +- Docker Compose path is blocked because Docker Desktop Linux engine is unavailable: `npipe:////./pipe/dockerDesktopLinuxEngine` not found. +- Local PostgreSQL service remains blocked: `Start-Service postgresql-x64-17` returns `Cannot open postgresql-x64-17 service on computer '.'`. + +## Deferred + +- Dedicated unit route remains deferred until the backend exposes unit-level student progress/deep-link authorization. +- Complete per-lesson completion/lock matrix remains a backend gap; the frontend only opens the current governed segment. +- Student-specific visual regression screenshots remain deferred because the local browser/backend stack is unavailable. +- Full Playwright execution against a healthy seeded backend remains blocked by local infrastructure. diff --git a/docs/ux-overhaul/student-responsive-review.md b/docs/ux-overhaul/student-responsive-review.md new file mode 100644 index 0000000..5b27770 --- /dev/null +++ b/docs/ux-overhaul/student-responsive-review.md @@ -0,0 +1,27 @@ +# Student Responsive Review + +Date: 2026-07-11 + +## Implemented Responsive Behavior + +- `/learn` uses responsive grid layouts that collapse to one column below tablet widths. +- Quick navigation uses auto-fit grid tracks and one-column layout at narrow mobile widths. +- Active learning cards collapse from content/progress/action columns to a single column below 900px. +- Progress summary collapses to one column below 480px. +- Course and achievement cards use tokenized spacing and avoid fixed-width text containers. +- `/learn/courses/:courseId` uses a two-column course hero that collapses below 900px. +- `StudentCurriculumComponent` collapses lesson state labels below 720px so the hierarchy remains readable at mobile widths. +- Assessment confirmation uses the shared responsive dialog foundation. +- Certificate cards keep a two-column layout only at large widths and stack otherwise. + +## Verification + +- Build and unit tests passed after responsive CSS changes. +- Manual browser screenshot verification at 1440, 1280, 768, and 390 was not completed because the frontend/backend/database browser environment is not running locally. +- Playwright discovered the student smoke test, but execution failed at `/login` with `ERR_CONNECTION_REFUSED`. + +## Risks + +- Long real course titles should be visually checked in browser screenshots. +- Lesson viewer mobile sticky action behavior remains a deferred improvement. +- Canvas, interactive map, storybook, and long assessment choice content still need visual QA against seeded content. diff --git a/docs/ux-overhaul/student-session-recovery.md b/docs/ux-overhaul/student-session-recovery.md new file mode 100644 index 0000000..9d1d18e --- /dev/null +++ b/docs/ux-overhaul/student-session-recovery.md @@ -0,0 +1,28 @@ +# Student Session Recovery + +Date: 2026-07-11 + +## Current Recovery Rules + +- `/learn/lesson/:id` and legacy `/home/lesson/:id` both use `LessonViewComponent`. +- On lesson route load, the component calls `CoursesService.getCurrentSegment(unitProgressId)`. +- If the segment is `governed_available` and includes `lesson_id`, the component loads the lesson with `CoursesService.getLessonById(lesson_id)`. +- If the segment is unavailable, pending review, empty, or completed, the component renders a student-safe state instead of exposing raw IDs. +- If `getCurrentSegment()` returns 404, the component calls `CoursesService.getStudentCourses()` and looks for an active course with `unit_progress_id`. +- If an active replacement segment is found, it navigates to the recovered `unit_progress_id` and reloads the governed segment. +- If recovery fails, the component renders a retryable error state. +- Lesson completion calls `CoursesService.markSegmentCompleted()` and only advances or completes after the API succeeds. +- Completion failure leaves the lesson open and shows: "We could not save this lesson completion. Your lesson is still open, so try again before leaving." + +## Implemented In This Phase + +- Canonical exit and completion return now use `/learn`. +- Completion save state uses `role="status"` with polite live announcement. +- Completion failure uses `role="alert"` and does not close the lesson. +- Duplicate completion calls are blocked while a completion request is in progress. + +## Deferred + +- Local temporary draft preservation for unsent activity responses. +- Leave-with-unsaved-work confirmation tied to activity inputs. +- Dedicated session-expired panel that differentiates auth expiry from temporary API failure. diff --git a/docs/ux-overhaul/student-terminology-map.md b/docs/ux-overhaul/student-terminology-map.md new file mode 100644 index 0000000..4903203 --- /dev/null +++ b/docs/ux-overhaul/student-terminology-map.md @@ -0,0 +1,25 @@ +# Student Terminology Map + +Date: 2026-07-11 + +Visible student copy now prefers learning language while preserving technical route/API names. + +| Internal or legacy term | Student-visible term | Notes | +| --- | --- | --- | +| Product | Course, available learning | `/learn/products` remains as a transitional route/API-backed surface. | +| Learner product | Course-backed learning | API name unchanged. | +| Workspace | Learn | Student navigation does not expose `/workspace` as a normal destination. | +| Artifact | Resource | Not used in student course journey. | +| Pipeline | Course path, learning path | No student-facing pipeline copy added. | +| Launch | Start, continue, resume | Course actions use learning verbs. | +| Consumption | Learning, progress | Avoided. | +| Certification | Certificate, earned certificate | Avoids overstating external accreditation. | +| Badge | Badge | Kept distinct from certificate. | +| Course completion | Course complete | Kept distinct from certificate. | +| Segment | Lesson progress, governed lesson | Internal `unit_progress_id` remains hidden from learners. | + +## Remaining Technical Terms + +- `/learn/products` remains the route path for compatibility. +- `LearnerProduct` remains a frontend model/API wrapper. +- `unit_progress_id` remains the internal identifier for `/learn/lesson/:id`. diff --git a/frontend/src/app/app.routes.spec.ts b/frontend/src/app/app.routes.spec.ts index f5ad093..c520a07 100644 --- a/frontend/src/app/app.routes.spec.ts +++ b/frontend/src/app/app.routes.spec.ts @@ -3,6 +3,7 @@ import { routes } from './app.routes'; import { LearnerPortalComponent } from './pages/learner-portal/learner-portal.component'; import { LearnerProductsComponent } from './pages/learner-portal/learner-products.component'; import { LearnerResourcesComponent } from './pages/learner-portal/learner-resources.component'; +import { StudentCourseOverviewComponent } from './pages/learner-portal/student-course-overview.component'; import { LessonViewComponent } from './pages/lesson-view.component'; import { WorkspaceDashboardComponent } from './pages/workspace-dashboard/workspace-dashboard.component'; import { DemoReadinessComponent } from './pages/demo-readiness/demo-readiness.component'; @@ -81,6 +82,7 @@ describe('app routes', () => { 'learn', 'learn/', 'learn/products', + 'learn/courses/:courseId', 'learn/paths', 'learn/certificates', 'learn/resources', @@ -93,6 +95,7 @@ describe('app routes', () => { const learnerShell = learnRoute?.children?.find(route => route.path === ''); expect(learnerShell?.children?.find(route => route.path === '')?.component).toBe(LearnerPortalComponent); expect(learnerShell?.children?.find(route => route.path === 'products')?.component).toBe(LearnerProductsComponent); + expect(learnerShell?.children?.find(route => route.path === 'courses/:courseId')?.component).toBe(StudentCourseOverviewComponent); expect(learnerShell?.children?.find(route => route.path === 'resources')?.component).toBe(LearnerResourcesComponent); expect(learnerShell?.children?.find(route => route.path === 'lesson/:id')?.component).toBe(LessonViewComponent); expect(findRoute(routes, 'home/lesson/:id')?.component).toBe(LessonViewComponent); diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index ceb609b..6c0c417 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -34,6 +34,7 @@ import { ReviewCenterComponent } from './pages/review-center/review-center.compo import { LearnerPortalComponent } from './pages/learner-portal/learner-portal.component'; import { LearnerProductsComponent } from './pages/learner-portal/learner-products.component'; import { LearnerResourcesComponent } from './pages/learner-portal/learner-resources.component'; +import { StudentCourseOverviewComponent } from './pages/learner-portal/student-course-overview.component'; import { AccessGrantsComponent } from './pages/access-grants/access-grants.component'; import { WorkspaceAnalyticsComponent } from './pages/workspace-analytics/workspace-analytics.component'; import { WorkspaceDashboardComponent } from './pages/workspace-dashboard/workspace-dashboard.component'; @@ -356,6 +357,7 @@ export const routes: Routes = [ children: [ { path: '', component: LearnerPortalComponent }, { path: 'products', component: LearnerProductsComponent }, + { path: 'courses/:courseId', component: StudentCourseOverviewComponent }, { path: 'paths', component: ProgramsComponent }, { path: 'certificates', component: CertificationsComponent }, { path: 'resources', component: LearnerResourcesComponent }, diff --git a/frontend/src/app/components/echo-button/echo-button.component.ts b/frontend/src/app/components/echo-button/echo-button.component.ts index a65c809..8156cfe 100644 --- a/frontend/src/app/components/echo-button/echo-button.component.ts +++ b/frontend/src/app/components/echo-button/echo-button.component.ts @@ -10,11 +10,13 @@ import { CommonModule } from '@angular/common'; @@ -22,6 +24,7 @@ import { CommonModule } from '@angular/common'; }) export class EchoButtonComponent { @Input() label: string = 'Echo Button'; - @Input() color: 'primary' | 'accent' | 'warn' | 'tertiary' = 'primary'; + @Input() color: 'primary' | 'accent' | 'secondary' | 'warn' | 'tertiary' = 'primary'; @Input() disabled = false; + @Input() ariaLabel = ''; } diff --git a/frontend/src/app/components/student-curriculum/student-curriculum.component.ts b/frontend/src/app/components/student-curriculum/student-curriculum.component.ts new file mode 100644 index 0000000..42dc123 --- /dev/null +++ b/frontend/src/app/components/student-curriculum/student-curriculum.component.ts @@ -0,0 +1,308 @@ +import { CommonModule } from '@angular/common'; +import { Component, EventEmitter, Input, Output } from '@angular/core'; + +export type StudentLessonState = 'included' | 'current' | 'complete' | 'locked' | 'unavailable'; + +export interface StudentCurriculumLesson { + id: string; + title: string; + objective?: string; + durationMinutes?: number; + activityCount: number; + state: StudentLessonState; + stateLabel: string; + stateDescription: string; + canOpen: boolean; +} + +export interface StudentCurriculumUnit { + id: string; + title: string; + order?: number; + progressLabel: string; + lessons: StudentCurriculumLesson[]; +} + +@Component({ + selector: 'app-student-curriculum', + standalone: true, + imports: [CommonModule], + template: ` +
+
+
+

Course path

+

Units and lessons

+
+

{{ summaryLabel }}

+
+ +
    +
  1. +
    +
    +

    Unit {{ unitIndex + 1 }}

    +

    {{ unit.title }}

    +
    + {{ unit.progressLabel }} +
    + +
      +
    1. + +
    2. +
    +
  2. +
+ + +
+

No lessons are listed yet

+

This course does not have a learner-ready curriculum hierarchy available from the current course data.

+
+
+
+ `, + styles: [` + .student-curriculum { + background: var(--ee-surface); + border: 1px solid var(--ee-border); + border-radius: var(--ee-radius-lg, 8px); + box-shadow: var(--ee-shadow-soft); + display: grid; + gap: var(--ee-space-4, 1rem); + padding: clamp(1rem, 3vw, 1.5rem); + } + + .student-curriculum__header, + .student-curriculum__unit header { + align-items: start; + display: flex; + gap: 1rem; + justify-content: space-between; + } + + .student-curriculum__header > p, + .student-curriculum__unit-progress, + .student-curriculum__lesson-objective, + .student-curriculum__lesson-meta, + .student-curriculum__state small, + .student-curriculum__empty p { + color: var(--ee-text-soft); + line-height: 1.55; + } + + .student-curriculum__eyebrow { + color: var(--ee-text-muted); + font-size: 0.76rem; + font-weight: 900; + letter-spacing: 0.12em; + margin: 0 0 0.3rem; + text-transform: uppercase; + } + + h2, + h3, + p { + letter-spacing: 0; + margin: 0; + } + + h2 { + font-size: clamp(1.35rem, 3vw, 1.8rem); + } + + h3 { + font-size: 1.1rem; + } + + .student-curriculum__units, + .student-curriculum__lessons { + display: grid; + gap: 0.85rem; + list-style: none; + margin: 0; + padding: 0; + } + + .student-curriculum__unit { + background: var(--ee-surface-muted, #f8fafc); + border: 1px solid var(--ee-border); + border-radius: var(--ee-radius, 6px); + display: grid; + gap: 0.75rem; + padding: 1rem; + } + + .student-curriculum__unit-progress { + background: var(--ee-surface); + border: 1px solid var(--ee-border); + border-radius: 999px; + display: inline-flex; + font-size: 0.8rem; + font-weight: 800; + padding: 0.35rem 0.65rem; + white-space: nowrap; + } + + .student-curriculum__lesson { + align-items: start; + background: var(--ee-surface); + border: 1px solid var(--ee-border); + border-radius: var(--ee-radius, 6px); + color: var(--ee-text); + cursor: pointer; + display: grid; + gap: 0.85rem; + grid-template-columns: auto minmax(0, 1fr) minmax(9rem, 0.28fr); + min-height: 4.25rem; + padding: 0.85rem; + text-align: left; + width: 100%; + } + + .student-curriculum__lesson:focus-visible { + outline: 3px solid var(--ee-focus-ring); + outline-offset: 3px; + } + + .student-curriculum__lesson:disabled { + cursor: not-allowed; + opacity: 0.82; + } + + .student-curriculum__lesson--current { + border-color: color-mix(in srgb, var(--ee-action-primary) 45%, var(--ee-border)); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ee-action-primary) 18%, transparent); + } + + .student-curriculum__lesson--complete { + border-color: var(--ee-status-success-border, #22c55e); + } + + .student-curriculum__lesson--locked { + background: color-mix(in srgb, var(--ee-surface-muted, #f8fafc) 85%, var(--ee-surface)); + } + + .student-curriculum__lesson-index { + align-items: center; + background: var(--ee-surface-muted, #f8fafc); + border: 1px solid var(--ee-border); + border-radius: 999px; + display: inline-flex; + font-weight: 900; + height: 2rem; + justify-content: center; + width: 2rem; + } + + .student-curriculum__lesson-main { + display: grid; + gap: 0.3rem; + min-width: 0; + } + + .student-curriculum__lesson-title { + font-weight: 900; + overflow-wrap: anywhere; + } + + .student-curriculum__lesson-meta { + display: flex; + flex-wrap: wrap; + gap: 0.55rem; + font-size: 0.8rem; + font-weight: 700; + } + + .student-curriculum__state { + align-items: start; + display: grid; + gap: 0.25rem; + justify-items: end; + text-align: right; + } + + .student-curriculum__state > span { + border: 1px solid var(--ee-border); + border-radius: 999px; + display: inline-flex; + font-size: 0.75rem; + font-weight: 900; + padding: 0.35rem 0.65rem; + white-space: nowrap; + } + + .student-curriculum__state[data-state="current"] > span { + background: var(--ee-color-action-primary-background, #0f766e); + border-color: var(--ee-color-action-primary-border, #0f766e); + color: var(--ee-color-action-primary-text, #fff); + } + + .student-curriculum__state[data-state="complete"] > span { + background: var(--ee-status-success-bg, #dcfce7); + border-color: var(--ee-status-success-border, #22c55e); + color: var(--ee-status-success-text, #166534); + } + + .student-curriculum__state[data-state="locked"] > span, + .student-curriculum__state[data-state="unavailable"] > span { + background: var(--ee-surface-muted, #f8fafc); + color: var(--ee-text-soft); + } + + .student-curriculum__empty { + border: 1px dashed var(--ee-border); + border-radius: var(--ee-radius, 6px); + display: grid; + gap: 0.35rem; + padding: 1rem; + } + + @media (max-width: 720px) { + .student-curriculum__header, + .student-curriculum__unit header { + display: grid; + } + + .student-curriculum__lesson { + grid-template-columns: auto minmax(0, 1fr); + } + + .student-curriculum__state { + grid-column: 1 / -1; + justify-items: start; + padding-inline-start: 2.85rem; + text-align: left; + } + } + `], +}) +export class StudentCurriculumComponent { + @Input() units: StudentCurriculumUnit[] = []; + @Input() summaryLabel = 'Course curriculum'; + @Output() openLesson = new EventEmitter(); +} diff --git a/frontend/src/app/models/lesson.ts b/frontend/src/app/models/lesson.ts index a48dd69..d8cace5 100644 --- a/frontend/src/app/models/lesson.ts +++ b/frontend/src/app/models/lesson.ts @@ -35,6 +35,7 @@ export interface Lesson { assessment?: string; review_status?: 'draft' | 'reviewed' | 'approved' | string; reviewed_by?: string; + order?: number; duration_minutes?: number; sources?: Source[]; is_ready_for_approval?: boolean; diff --git a/frontend/src/app/pages/assessment-detail/assessment-detail.component.html b/frontend/src/app/pages/assessment-detail/assessment-detail.component.html index d35e30b..0fa95f7 100644 --- a/frontend/src/app/pages/assessment-detail/assessment-detail.component.html +++ b/frontend/src/app/pages/assessment-detail/assessment-detail.component.html @@ -1,6 +1,6 @@
- Back to programs + Back to learning paths Assessment

{{ assessment.title }}

-

{{ assessment.description || 'Answer each question to check your mastery before certification review.' }}

+

{{ assessment.description || 'Answer each question to check your learning before any supported certificate review.' }}

Passing score: {{ assessment.passing_score | number:'1.0-0' }}%

+

+ {{ answeredQuestionCount }} of {{ questionCount }} questions answered. +

{{ asses

Assessment questions

-
+

Question {{ index + 1 }}

{{ question.prompt }}

- {{ question.points }} pts + {{ question.points }} point{{ question.points === 1 ? '' : 's' }}
@@ -96,7 +99,7 @@

{{ question.prompt }}

All questions must be answered before submission.

@@ -108,14 +111,14 @@

{{ question.prompt }}

- {{ result.passed ? 'Passed' : 'Needs another try' }} + {{ assessmentResultLabel }}

Assessment result

You scored {{ result.score }} out of {{ result.max_score }} points ({{ result.percentage | number:'1.0-0' }}%).

- Open certifications + Open achievements
@@ -130,11 +133,26 @@

Assessment result

- {{ answer.is_correct ? 'Correct' : 'Incorrect' }} + {{ answer.is_correct ? 'Confirmed understanding' : 'Review this idea' }}

Your answer: {{ answer.answer }}

Points earned: {{ answer.awarded_points }}

+ +
diff --git a/frontend/src/app/pages/assessment-detail/assessment-detail.component.spec.ts b/frontend/src/app/pages/assessment-detail/assessment-detail.component.spec.ts index 22c26b3..c3fccd6 100644 --- a/frontend/src/app/pages/assessment-detail/assessment-detail.component.spec.ts +++ b/frontend/src/app/pages/assessment-detail/assessment-detail.component.spec.ts @@ -109,7 +109,7 @@ describe('AssessmentDetailComponent', () => { const compiled = fixture.nativeElement as HTMLElement; expect(compiled.textContent).toContain('Assessment not ready for learner submission'); - expect(compiled.textContent).not.toContain('Submit Assessment'); + expect(compiled.textContent).not.toContain('Submit assessment'); expect(compiled.querySelector('[data-echo-state="unavailable"]')).not.toBeNull(); }); @@ -141,6 +141,23 @@ describe('AssessmentDetailComponent', () => { expect(compiled.textContent).toContain('Preparing assessment'); }); + it('opens final confirmation before submitting a completed assessment', () => { + programsServiceSpy.getAssessment.and.returnValue(of(buildAvailableAssessment())); + + fixture = TestBed.createComponent(AssessmentDetailComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + component.answers['question-1'] = '4'; + component.requestSubmitConfirmation(); + fixture.detectChanges(); + + const dialog = fixture.nativeElement.querySelector('[role="dialog"]') as HTMLElement; + expect(dialog).not.toBeNull(); + expect(dialog.textContent).toContain('Submit assessment'); + expect(programsServiceSpy.submitAssessment).not.toHaveBeenCalled(); + }); + it('shows a clear submit-pending state without changing submit behavior', () => { const pendingSubmit = new Subject(); programsServiceSpy.getAssessment.and.returnValue(of(buildAvailableAssessment())); @@ -158,6 +175,7 @@ describe('AssessmentDetailComponent', () => { const compiled = fixture.nativeElement as HTMLElement; expect(component.submitting).toBeTrue(); + expect(component.showSubmitConfirmation).toBeTrue(); expect(compiled.querySelector('form')?.getAttribute('aria-busy')).toBe('true'); expect(compiled.querySelector('[data-echo-loading="compact"]')).not.toBeNull(); expect(compiled.textContent).toContain('Submitting assessment'); @@ -182,5 +200,7 @@ describe('AssessmentDetailComponent', () => { expect(failurePanel?.getAttribute('role')).toBe('alert'); expect(failurePanel?.getAttribute('aria-label')).toBe('We could not submit your assessment'); expect(compiled.textContent).toContain('We could not submit your assessment'); + expect(component.showSubmitConfirmation).toBeTrue(); + expect(component.answers['question-1']).toBe('4'); }); }); diff --git a/frontend/src/app/pages/assessment-detail/assessment-detail.component.ts b/frontend/src/app/pages/assessment-detail/assessment-detail.component.ts index 7a57466..bc01fd9 100644 --- a/frontend/src/app/pages/assessment-detail/assessment-detail.component.ts +++ b/frontend/src/app/pages/assessment-detail/assessment-detail.component.ts @@ -5,6 +5,7 @@ import { ActivatedRoute, RouterLink } from '@angular/router'; import { forkJoin } from 'rxjs'; import { EchoButtonComponent } from '../../components/echo-button/echo-button.component'; +import { EchoConfirmationDialogComponent } from '../../components/echo-confirmation-dialog/echo-confirmation-dialog.component'; import { EchoLoadingStateComponent } from '../../components/echo-loading-state/echo-loading-state.component'; import { EchoStatePanelComponent } from '../../components/echo-state-panel/echo-state-panel.component'; import { @@ -18,7 +19,7 @@ import { ToastService } from '../../services/toast.service'; @Component({ selector: 'app-assessment-detail', standalone: true, - imports: [CommonModule, FormsModule, RouterLink, EchoButtonComponent, EchoLoadingStateComponent, EchoStatePanelComponent], + imports: [CommonModule, FormsModule, RouterLink, EchoButtonComponent, EchoConfirmationDialogComponent, EchoLoadingStateComponent, EchoStatePanelComponent], templateUrl: './assessment-detail.component.html', styleUrl: './assessment-detail.component.scss' }) @@ -31,6 +32,7 @@ export class AssessmentDetailComponent implements OnInit { submitting = false; loadErrorMessage = ''; submitErrorMessage = ''; + showSubmitConfirmation = false; constructor( private route: ActivatedRoute, @@ -91,12 +93,43 @@ export class AssessmentDetailComponent implements OnInit { : 'Submitting your assessment and saving your latest learner evidence.'; } + get answeredQuestionCount(): number { + return this.assessment?.questions.filter(question => !!this.answers[question.id]?.trim()).length || 0; + } + + get questionCount(): number { + return this.assessment?.questions.length || 0; + } + + get assessmentResultLabel(): string { + if (!this.result) { + return ''; + } + return this.result.passed ? 'Passed' : 'Needs more practice'; + } + + requestSubmitConfirmation(): void { + if (!this.canSubmit) { + this.submitErrorMessage = 'Answer every question before submitting this assessment.'; + return; + } + this.submitErrorMessage = ''; + this.showSubmitConfirmation = true; + } + + cancelSubmitConfirmation(): void { + if (!this.submitting) { + this.showSubmitConfirmation = false; + } + } + submit(): void { if (!this.assessment || !this.canSubmit) { return; } this.submitting = true; + this.showSubmitConfirmation = true; this.submitErrorMessage = ''; this.awardedCertifications = []; const payload = this.assessment.questions.map(question => ({ @@ -109,6 +142,7 @@ export class AssessmentDetailComponent implements OnInit { this.result = result; if (!this.assessment?.program_id) { this.submitting = false; + this.showSubmitConfirmation = false; this.toastService.show('Assessment submitted.', result.passed ? 'success' : 'error'); return; } @@ -117,6 +151,7 @@ export class AssessmentDetailComponent implements OnInit { next: (certifications) => { if (certifications.length === 0) { this.submitting = false; + this.showSubmitConfirmation = false; this.toastService.show('Assessment submitted.', result.passed ? 'success' : 'error'); return; } @@ -125,6 +160,7 @@ export class AssessmentDetailComponent implements OnInit { next: (evaluations) => { this.awardedCertifications = evaluations.filter(evaluation => evaluation.awarded); this.submitting = false; + this.showSubmitConfirmation = false; this.toastService.show( this.awardedCertifications.length > 0 ? 'Assessment submitted and certification status updated.' @@ -134,18 +170,21 @@ export class AssessmentDetailComponent implements OnInit { }, error: () => { this.submitting = false; + this.showSubmitConfirmation = false; this.toastService.show('Assessment submitted, but certification status could not be refreshed.', 'error'); } }); }, error: () => { this.submitting = false; + this.showSubmitConfirmation = false; this.toastService.show('Assessment submitted.', result.passed ? 'success' : 'error'); } }); }, error: () => { this.submitting = false; + this.showSubmitConfirmation = true; this.submitErrorMessage = 'We could not submit this assessment. Your answers are still here, so review them and try again.'; this.toastService.show('Unable to submit the assessment.', 'error'); } diff --git a/frontend/src/app/pages/certifications/certifications.component.html b/frontend/src/app/pages/certifications/certifications.component.html index 8a226a6..f104a01 100644 --- a/frontend/src/app/pages/certifications/certifications.component.html +++ b/frontend/src/app/pages/certifications/certifications.component.html @@ -1,31 +1,46 @@
- Certifications -

Proof of progress, readiness, and completion

+ Achievements +

Certificates earned through learning

- Certifications are issued after required coursework is complete and assessment thresholds are met. Review each award and its supporting requirements here. + Certificates appear here when EchoEd records that course or assessment requirements are complete. Badges and course completion milestones may use separate platform records.

-
-
-
-
-
+ -
-

No certifications earned yet

-

Complete program coursework and pass the linked assessments to unlock certification awards.

- Browse programs -
+ + + + Browse learning paths -
+
- Issued + Earned certificate

{{ issued.certification.title }}

-

{{ issued.certification.description || 'Issued after meeting coursework and assessment requirements.' }}

+

{{ issued.certification.description || 'Earned after meeting supported learning and assessment requirements.' }}

Awarded

@@ -34,15 +49,19 @@

{{ issued.certification.title }}
-

Requirement summary

-
    -
  • - {{ requirement.requirement_type === 'course_completion' ? 'Course completion required' : 'Assessment pass required' }} - at {{ requirement.minimum_score | number:'1.0-0' }}% or higher -
  • -
+

Requirement summary

+
    +
  • + {{ requirement.requirement_type === 'course_completion' ? 'Course completion required' : 'Assessment pass required' }} + at {{ requirement.minimum_score | number:'1.0-0' }}% or higher +
  • +
+

+ This certificate records completion inside EchoEd. It is not presented as external accreditation unless the issuing organization separately provides that status. +

+

Score snapshot recorded at issuance: {{ issued.score_snapshot | number:'1.0-0' }}%

diff --git a/frontend/src/app/pages/certifications/certifications.component.spec.ts b/frontend/src/app/pages/certifications/certifications.component.spec.ts new file mode 100644 index 0000000..6f8be95 --- /dev/null +++ b/frontend/src/app/pages/certifications/certifications.component.spec.ts @@ -0,0 +1,82 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { of, throwError } from 'rxjs'; + +import { CertificationsComponent } from './certifications.component'; +import { ProgramsService } from '../../services/programs.service'; +import { ToastService } from '../../services/toast.service'; + +class MockProgramsService { + certificationsResponse = of([ + { + id: 'student-cert-1', + student_id: 'student-1', + certification_id: 'cert-1', + awarded_at: '2026-01-05T12:00:00Z', + score_snapshot: 92, + certification: { + id: 'cert-1', + program_id: 'program-1', + title: 'Introduction to Africa Certificate', + description: 'Earned through course completion and assessment.', + created_at: '2026-01-01T00:00:00Z', + requirements: [{ id: 'req-1', requirement_type: 'course_completion', course_id: 'course-1' }], + }, + }, + ]); + + getMyCertifications = jasmine.createSpy('getMyCertifications').and.callFake(() => this.certificationsResponse); +} + +class MockToastService { + show = jasmine.createSpy('show'); +} + +describe('CertificationsComponent', () => { + let fixture: ComponentFixture; + let programsService: MockProgramsService; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [RouterTestingModule, CertificationsComponent], + providers: [ + { provide: ProgramsService, useClass: MockProgramsService }, + { provide: ToastService, useClass: MockToastService }, + ], + }).compileComponents(); + + programsService = TestBed.inject(ProgramsService) as unknown as MockProgramsService; + }); + + it('renders earned certificates as student achievements without download claims', () => { + fixture = TestBed.createComponent(CertificationsComponent); + fixture.detectChanges(); + + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.textContent).toContain('Certificates earned through learning'); + expect(compiled.textContent).toContain('Introduction to Africa Certificate'); + expect(compiled.textContent).toContain('This certificate records completion inside EchoEd'); + expect(compiled.textContent).not.toContain('Download'); + }); + + it('renders a purposeful no-certificate state', () => { + programsService.certificationsResponse = of([]); + fixture = TestBed.createComponent(CertificationsComponent); + fixture.detectChanges(); + + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.querySelector('[data-echo-state="empty"]')).not.toBeNull(); + expect(compiled.textContent).toContain('Your certificates will appear here'); + expect(compiled.textContent).toContain('Browse learning paths'); + }); + + it('renders a recoverable certificate load error', () => { + programsService.certificationsResponse = throwError(() => new Error('load failed')); + fixture = TestBed.createComponent(CertificationsComponent); + fixture.detectChanges(); + + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.querySelector('[data-echo-state="error"]')).not.toBeNull(); + expect(compiled.textContent).toContain('We could not load your certificates'); + }); +}); diff --git a/frontend/src/app/pages/certifications/certifications.component.ts b/frontend/src/app/pages/certifications/certifications.component.ts index bec71af..af0ae0c 100644 --- a/frontend/src/app/pages/certifications/certifications.component.ts +++ b/frontend/src/app/pages/certifications/certifications.component.ts @@ -2,6 +2,8 @@ import { CommonModule } from '@angular/common'; import { Component, OnInit } from '@angular/core'; import { RouterLink } from '@angular/router'; +import { EchoLoadingStateComponent } from '../../components/echo-loading-state/echo-loading-state.component'; +import { EchoStatePanelComponent } from '../../components/echo-state-panel/echo-state-panel.component'; import { StudentCertification } from '../../models/program'; import { ProgramsService } from '../../services/programs.service'; import { ToastService } from '../../services/toast.service'; @@ -9,13 +11,14 @@ import { ToastService } from '../../services/toast.service'; @Component({ selector: 'app-certifications', standalone: true, - imports: [CommonModule, RouterLink], + imports: [CommonModule, RouterLink, EchoLoadingStateComponent, EchoStatePanelComponent], templateUrl: './certifications.component.html', styleUrl: './certifications.component.scss' }) export class CertificationsComponent implements OnInit { certifications: StudentCertification[] = []; loading = true; + loadErrorMessage = ''; constructor( private programsService: ProgramsService, @@ -27,11 +30,20 @@ export class CertificationsComponent implements OnInit { next: (certifications) => { this.certifications = certifications; this.loading = false; + this.loadErrorMessage = ''; }, error: () => { this.loading = false; + this.certifications = []; + this.loadErrorMessage = 'We could not load your certificates right now. Your learning progress was not changed.'; this.toastService.show('Unable to load certifications right now.', 'error'); } }); } + + retryLoad(): void { + this.loading = true; + this.loadErrorMessage = ''; + this.ngOnInit(); + } } diff --git a/frontend/src/app/pages/learner-portal/learner-portal.component.spec.ts b/frontend/src/app/pages/learner-portal/learner-portal.component.spec.ts new file mode 100644 index 0000000..6490025 --- /dev/null +++ b/frontend/src/app/pages/learner-portal/learner-portal.component.spec.ts @@ -0,0 +1,182 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { BehaviorSubject, of, throwError } from 'rxjs'; + +import { LearnerPortalComponent } from './learner-portal.component'; +import { AnalyticsService } from '../../services/analytics.service'; +import { BadgesService } from '../../services/badges.service'; +import { CoursesService } from '../../services/courses.service'; +import { PermissionsService } from '../../services/permissions.service'; +import { ProgramsService } from '../../services/programs.service'; +import { ToastService } from '../../services/toast.service'; +import { V2PlatformService } from '../../services/v2-platform.service'; +import { StudentCourseWithDetails } from '../../models/student-course-with-details.model'; + +function makeStudentCourse(overrides: Partial = {}): StudentCourseWithDetails { + return { + id: 'student-course-1', + student_id: 'student-1', + course_id: 'course-1', + enrolled_on: '2026-01-01T00:00:00Z', + status: 'active', + progress: 42, + course: { + id: 'course-1', + title: 'Introduction to Africa', + description: 'Explore geography, history, and community knowledge.', + created_at: new Date(), + units: [], + }, + unit_progress_id: 'unit-progress-1', + continuation_guidance: { + support_state: 'normal', + learner_title: 'Your next lesson is ready', + learner_message: 'Continue where you already have momentum.', + recommended_action: 'continue', + evidence_source: 'current_progress', + }, + ...overrides, + }; +} + +class MockCoursesService { + studentCoursesResponse = of([makeStudentCourse()]); + coursesResponse = of([ + { + id: 'course-2', + title: 'African Kingdoms', + description: 'Learn about trade, leadership, and daily life.', + created_at: new Date(), + units: [], + }, + ]); + getStudentCourses = jasmine.createSpy('getStudentCourses').and.callFake(() => this.studentCoursesResponse); + getCourseProgress = jasmine.createSpy('getCourseProgress').and.returnValue(of(42)); + getCourses = jasmine.createSpy('getCourses').and.callFake(() => this.coursesResponse); + startCourse = jasmine.createSpy('startCourse').and.returnValue(of({ + delivery_state: 'governed_available', + unit_progress_id: 'unit-progress-1', + lesson_id: 'lesson-1', + })); + enrollInCourse = jasmine.createSpy('enrollInCourse').and.returnValue(of({})); +} + +class MockAnalyticsService { + getStudentProgress = jasmine.createSpy('getStudentProgress').and.returnValue(of({ + metrics: { + lessons_completed: 3, + units_completed: 1, + courses_completed: 0, + streak_days: 0, + }, + badge_progress: [], + })); +} + +class MockBadgesService { + getStudentBadges = jasmine.createSpy('getStudentBadges').and.returnValue(of([])); +} + +class MockProgramsService { + getPrograms = jasmine.createSpy('getPrograms').and.returnValue(of([])); + getMyCertifications = jasmine.createSpy('getMyCertifications').and.returnValue(of([])); +} + +class MockV2PlatformService { + productsResponse = of([]); + getLearnerProducts = jasmine.createSpy('getLearnerProducts').and.callFake(() => this.productsResponse); +} + +class MockPermissionsService { + user$ = new BehaviorSubject({ user_id: 'student-1', role: 'student' }); +} + +class MockToastService { + show = jasmine.createSpy('show'); +} + +describe('LearnerPortalComponent', () => { + let fixture: ComponentFixture; + let component: LearnerPortalComponent; + let coursesService: MockCoursesService; + let router: Router; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [RouterTestingModule, LearnerPortalComponent], + providers: [ + { provide: CoursesService, useClass: MockCoursesService }, + { provide: AnalyticsService, useClass: MockAnalyticsService }, + { provide: BadgesService, useClass: MockBadgesService }, + { provide: ProgramsService, useClass: MockProgramsService }, + { provide: PermissionsService, useClass: MockPermissionsService }, + { provide: ToastService, useClass: MockToastService }, + { provide: V2PlatformService, useClass: MockV2PlatformService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(LearnerPortalComponent); + component = fixture.componentInstance; + coursesService = TestBed.inject(CoursesService) as unknown as MockCoursesService; + router = TestBed.inject(Router); + spyOn(router, 'navigate').and.resolveTo(true); + fixture.detectChanges(); + }); + + it('renders /learn as a learner-first home with the next action first', () => { + const compiled = fixture.nativeElement as HTMLElement; + + expect(compiled.querySelector('h1')?.textContent).toContain('Continue your learning'); + expect(compiled.textContent).toContain('Next learning'); + expect(compiled.textContent).toContain('Introduction to Africa'); + expect(compiled.textContent).toContain('Continue where you already have momentum.'); + expect(compiled.textContent).not.toContain('Your products and learning paths'); + }); + + it('prioritizes active student courses over available learning', () => { + expect(component.activeStudentCourse?.course.title).toBe('Introduction to Africa'); + expect(component.continuationTitle).toBe('Introduction to Africa'); + expect(component.activeCourseStatusLabel).toBe('In progress'); + }); + + it('navigates course continuation through the canonical /learn lesson route', () => { + component.startCourse(makeStudentCourse()); + + expect(coursesService.startCourse).toHaveBeenCalledWith({ course_id: 'course-1' }); + expect(router.navigate).toHaveBeenCalledWith(['/learn/lesson', 'unit-progress-1']); + }); + + it('renders the no-active-course state without inventing assignments', () => { + coursesService.studentCoursesResponse = of([]); + coursesService.coursesResponse = of([]); + + fixture = TestBed.createComponent(LearnerPortalComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.textContent).toContain('Your next lesson will appear here'); + expect(compiled.textContent).toContain('When a course is assigned or you start a course'); + }); + + it('renders available course cards from the existing course catalog', () => { + const compiled = fixture.nativeElement as HTMLElement; + + expect(compiled.textContent).toContain('Available learning'); + expect(compiled.textContent).toContain('African Kingdoms'); + expect(compiled.textContent).toContain('Start course'); + }); + + it('renders a recoverable course load error state', () => { + coursesService.studentCoursesResponse = throwError(() => new Error('load failed')); + + fixture = TestBed.createComponent(LearnerPortalComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.querySelector('[data-echo-state="error"]')).not.toBeNull(); + expect(compiled.textContent).toContain('We could not restore your learning'); + }); +}); diff --git a/frontend/src/app/pages/learner-portal/learner-portal.component.ts b/frontend/src/app/pages/learner-portal/learner-portal.component.ts index d1b85cd..160b31f 100644 --- a/frontend/src/app/pages/learner-portal/learner-portal.component.ts +++ b/frontend/src/app/pages/learner-portal/learner-portal.component.ts @@ -1,10 +1,20 @@ import { CommonModule } from '@angular/common'; import { Component, OnInit, inject } from '@angular/core'; import { Router, RouterModule } from '@angular/router'; -import { forkJoin } from 'rxjs'; +import { take } from 'rxjs/operators'; + import { EchoButtonComponent } from '../../components/echo-button/echo-button.component'; +import { EchoLoadingStateComponent } from '../../components/echo-loading-state/echo-loading-state.component'; +import { EchoStatePanelComponent } from '../../components/echo-state-panel/echo-state-panel.component'; +import { StudentBadge } from '../../models/badge'; +import { Course } from '../../models/course'; +import { Program, StudentCertification } from '../../models/program'; +import { ContinuationGuidance, StudentCourseWithDetails } from '../../models/student-course-with-details.model'; import { LearnerProduct } from '../../models/v2-platform.model'; +import { AnalyticsService, StudentProgressResponse } from '../../services/analytics.service'; +import { BadgesService } from '../../services/badges.service'; import { CoursesService } from '../../services/courses.service'; +import { PermissionsService } from '../../services/permissions.service'; import { ProgramsService } from '../../services/programs.service'; import { ToastService } from '../../services/toast.service'; import { V2PlatformService } from '../../services/v2-platform.service'; @@ -12,130 +22,395 @@ import { V2PlatformService } from '../../services/v2-platform.service'; @Component({ selector: 'app-learner-portal', standalone: true, - imports: [CommonModule, RouterModule, EchoButtonComponent], + imports: [CommonModule, RouterModule, EchoButtonComponent, EchoLoadingStateComponent, EchoStatePanelComponent], template: ` -
-
-

Learner Portal

-

Your products and learning paths

- Products organize your access. Lessons still open through the existing governed course runtime. +
+
+
+

Learn

+

Continue your learning

+

+ Start with your next lesson, review your progress, and find learning paths that are ready for you. +

+
+
- - -
-
- {{ enrolledProducts.length }} - Enrolled products -
-
- {{ availableProducts.length }} - Available products -
-
- {{ programCount }} - Learning paths -
-
- {{ certificateCount }} - Certificates +
+
+
+
+

Next learning

+

{{ continuationTitle }}

+
+ {{ activeCourseStatusLabel }} +
+ + + + + + +

{{ activeStudentCourse.course.title }}

+

{{ activeStudentCourse.course.description }}

+

{{ activeStudentCourseReason }}

+ +
+
+ Your progress + {{ activeCourseProgress | number:'1.0-0' }}% +
+
+
+
+
+ +
+

{{ activeContinuationEyebrow }}

+

{{ activeContinuationTitle }}

+

{{ activeReinforcementMessage }}

+
    +
  • {{ prompt }}
  • +
+
+ + + View course path +
+ +
-
-
-
-
-

Next Action

-

{{ nextProduct?.title || 'No active product yet' }}

+
-

{{ nextProduct?.learner_visibility || 'Enroll in a product to start a governed learning path.' }}

- - -

- Access is active for this product. Runtime delivery is not connected yet. -

+
-
-
+
+
-

Products

-

Current enrollments

+

Assigned or active learning

+

Your current courses

- View all + View all courses
-
-
-

{{ product.title }}

-

{{ product.description || product.learner_visibility }}

- Access grant active ยท governed lessons still use runtime rules - {{ label(product.source) }} ยท {{ label(product.enrollment_status || 'enrolled') }} - + + +
+
+
+ {{ statusLabel(course.status, course.progress) }} +

{{ course.course.title }}

+

{{ course.course.description }}

+
+
+
+ Progress + {{ normalizedProgress(course.progress) | number:'1.0-0' }}% +
+
+
+
+
+ + Course path
- -

Your enrolled products will appear here after course enrollment.

-
+ +
+ +
+
+
+
+

Available learning

+

Courses you can start

+
+ Browse courses +
+ + +
+
+
+ Available +

{{ course.title }}

+

{{ course.description }}

+
+ + View course +
+
+
+ Available +

{{ product.title }}

+

{{ product.description || product.learner_visibility }}

+
+ + View course +

This resource is visible to you, but course lessons are not connected yet.

+
+
+ +
+ +
+
+
+

Achievements

+

Badges and certificates

+
+ View all +
+ +
+
+ +
+

{{ badge.badge?.title || 'Earned badge' }}

+

{{ badge.badge?.description || 'You earned this badge through your learning.' }}

+
+
+
+ +
+

{{ certificate.certification.title }}

+

Awarded {{ certificate.awarded_at | date:'mediumDate' }}

+
+
+
+ +
`, styles: [` - .learner-portal { color: var(--ee-text); display: grid; gap: 1rem; padding: clamp(1rem, 3vw, 2rem); } - header, .panel, .summary article { animation: ee-motion-card-enter var(--ee-duration-slow, 260ms) var(--ee-easing-standard, ease) both; background: linear-gradient(145deg, rgba(255,255,255,.1), rgba(255,255,255,.035)), var(--ee-surface); border: 1px solid var(--ee-border); border-radius: var(--ee-radius); box-shadow: var(--ee-shadow-soft); backdrop-filter: blur(22px) saturate(1.2); padding: clamp(1rem, 3vw, 1.5rem); transition: transform var(--ee-duration-base, 180ms) var(--ee-easing-standard, ease); } - .panel:hover, .summary article:hover { transform: var(--ee-hover-lift, translateY(-3px)); } - header { padding: clamp(1.75rem, 5vw, 4rem); } - header p, .panel__heading p { color: var(--ee-gold); font-size: .76rem; font-weight: 900; letter-spacing: .12em; margin: 0 0 .35rem; text-transform: uppercase; } - h1, h2, h3 { color: var(--ee-text); letter-spacing: 0; margin: 0; } - h1 { font-size: clamp(2.65rem, 7vw, 5.4rem); line-height: .92; max-width: 12ch; } - header span, .panel > p, .product p, .empty { color: var(--ee-text-soft); } - nav, .panel__heading, .summary, .cards { display: grid; gap: .75rem; } - nav { grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); } - nav a, .panel__heading a, .product button { border-radius: 6px; font-weight: 900; padding: .65rem .85rem; text-decoration: none; } - nav a { background: rgba(255,255,255,.055); border: 1px solid var(--ee-border); border-radius: 999px; color: var(--ee-text); text-align: center; transition: transform var(--ee-duration-base, 180ms) var(--ee-easing-standard, ease); } - nav a:hover { transform: var(--ee-hover-lift, translateY(-3px)); } - .summary { grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); } - .summary article { display: grid; gap: .2rem; } - .summary strong { background: linear-gradient(135deg, var(--ee-text), var(--ee-azure)); background-clip: text; color: transparent; font-size: 2rem; } - .summary span, .product span { color: var(--ee-text-soft); font-weight: 800; } - .product small { color: var(--ee-azure); font-weight: 800; } - .panel__heading { align-items: center; display: flex; justify-content: space-between; } - .panel__heading > span, .panel__heading a { background: linear-gradient(135deg, var(--ee-purple-strong), var(--ee-purple), var(--ee-azure)); border-radius: 999px; color: #fff; } - .cards { grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); } - .product { animation: ee-motion-card-enter var(--ee-duration-slow, 260ms) var(--ee-easing-standard, ease) both; background: rgba(255,255,255,.045); border: 1px solid var(--ee-border); border-radius: 18px; display: grid; gap: .65rem; padding: 1rem; transition: transform var(--ee-duration-base, 180ms) var(--ee-easing-standard, ease); } - .product:hover { transform: var(--ee-hover-lift, translateY(-3px)); } - .product button { background: linear-gradient(135deg, var(--ee-purple-strong), var(--ee-purple), var(--ee-azure)); border: 1px solid rgba(255,255,255,.18); color: #fff; cursor: pointer; transition: transform var(--ee-duration-fast, 120ms) var(--ee-easing-standard, ease); width: fit-content; } - .product button:active { transform: var(--ee-press-scale, scale(.98)); } - a:focus-visible, button:focus-visible { outline: 3px solid rgba(92,200,255,.25); outline-offset: 3px; } - @media (prefers-reduced-motion: reduce) { header, .panel, .summary article, .product { animation: none; } .panel:hover, .summary article:hover, nav a:hover, .product:hover, .product button:active { transform: none; } } + .learn-home { color: var(--ee-text); display: grid; gap: var(--space-layout-section, 1.5rem); padding: clamp(1rem, 3vw, 2rem); } + .learn-home__hero, .learn-card { background: var(--ee-surface); border: 1px solid var(--ee-border); border-radius: var(--ee-radius-lg, 8px); box-shadow: var(--ee-shadow-soft); padding: clamp(1rem, 3vw, 1.5rem); } + .learn-home__hero { display: grid; gap: 1.25rem; } + .learn-home__eyebrow { color: var(--ee-text-muted); font-size: .76rem; font-weight: 800; letter-spacing: .12em; margin: 0 0 .35rem; text-transform: uppercase; } + h1, h2, h3, p { letter-spacing: 0; margin: 0; } + h1 { font-size: clamp(2rem, 5vw, 3.35rem); line-height: 1.02; max-width: 12ch; } + h2 { font-size: clamp(1.25rem, 3vw, 1.65rem); } + h3 { font-size: 1rem; } + a { color: var(--ee-action-primary); font-weight: 800; text-decoration: underline; text-underline-offset: .2em; } + .learn-home__intro, .learn-card__copy, .learn-card__guidance, .learning-item p, .achievement-list p { color: var(--ee-text-soft); line-height: 1.6; } + .learn-home__quick-nav { display: grid; gap: .65rem; grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); } + .learn-home__quick-nav a { align-items: center; background: var(--ee-surface-muted, #f8fafc); border: 1px solid var(--ee-border); border-radius: var(--ee-radius, 6px); color: var(--ee-text); display: inline-flex; justify-content: center; min-height: 44px; padding: .65rem .85rem; text-decoration: none; } + .learn-home__grid { display: grid; gap: 1rem; grid-template-columns: minmax(0, 1.35fr) minmax(18rem, .65fr); } + .learn-home__grid--top { align-items: stretch; } + .learn-card { display: grid; gap: 1rem; min-width: 0; } + .learn-card--priority { border-color: color-mix(in srgb, var(--ee-action-primary) 30%, var(--ee-border)); } + .learn-card__heading { align-items: start; display: flex; gap: 1rem; justify-content: space-between; } + .learn-card__secondary-action { justify-self: start; } + .learn-card__title { color: var(--ee-text); font-size: clamp(1.25rem, 3vw, 1.75rem); font-weight: 800; } + .learn-status { background: var(--ee-surface-muted, #f8fafc); border: 1px solid var(--ee-border); border-radius: 999px; color: var(--ee-text); display: inline-flex; font-size: .76rem; font-weight: 800; line-height: 1.2; padding: .35rem .65rem; white-space: nowrap; } + .learn-summary { display: grid; gap: .75rem; grid-template-columns: repeat(2, minmax(0, 1fr)); } + .learn-summary div { background: var(--ee-surface-muted, #f8fafc); border: 1px solid var(--ee-border); border-radius: var(--ee-radius, 6px); padding: .85rem; } + .learn-summary strong { display: block; font-size: 1.65rem; line-height: 1; } + .learn-summary span, .learn-progress span { color: var(--ee-text-soft); font-size: .82rem; font-weight: 700; } + .learn-progress { background: var(--ee-surface-muted, #f8fafc); border: 1px solid var(--ee-border); border-radius: var(--ee-radius, 6px); display: grid; gap: .65rem; padding: .85rem; } + .learn-progress > div:first-child { align-items: center; display: flex; justify-content: space-between; } + .learn-progress strong { color: var(--ee-action-primary); } + .learn-progress__track { background: var(--ee-border); border-radius: 999px; height: .7rem; overflow: hidden; } + .learn-progress__bar { background: var(--ee-action-primary); border-radius: inherit; height: 100%; min-width: .15rem; } + .learn-progress--compact { padding: .65rem; } + .learn-support { border: 1px solid var(--ee-border); border-radius: var(--ee-radius, 6px); display: grid; gap: .45rem; padding: .85rem; } + .learn-support[data-support-state="remediation"] { background: var(--ee-status-warning-bg, #fef3c7); border-color: var(--ee-status-warning-border, #f59e0b); } + .learn-support[data-support-state="enrichment"] { background: var(--ee-status-success-bg, #dcfce7); border-color: var(--ee-status-success-border, #22c55e); } + .learn-support__label { color: var(--ee-text-muted); font-size: .72rem; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; } + .learn-support__title { font-weight: 800; } + .learn-support ul { margin: .25rem 0 0; padding-inline-start: 1.2rem; } + .learning-list { display: grid; gap: .85rem; } + .learning-list--compact { grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); } + .learning-item { align-items: start; background: var(--ee-surface-muted, #f8fafc); border: 1px solid var(--ee-border); border-radius: var(--ee-radius, 6px); display: grid; gap: .85rem; grid-template-columns: minmax(0, 1fr) minmax(10rem, .35fr) auto; padding: 1rem; } + .learning-list--compact .learning-item { grid-template-columns: 1fr; } + .achievement-list { display: grid; gap: .75rem; } + .achievement-list article { align-items: center; background: var(--ee-surface-muted, #f8fafc); border: 1px solid var(--ee-border); border-radius: var(--ee-radius, 6px); display: flex; gap: .75rem; padding: .85rem; } + .achievement-list img, .achievement-list__icon { border-radius: 999px; height: 2.5rem; object-fit: cover; width: 2.5rem; } + .achievement-list__icon { align-items: center; background: var(--ee-action-primary); color: var(--ee-action-primary-contrast, #fff); display: inline-flex; font-weight: 900; justify-content: center; } + a:focus-visible, button:focus-visible { outline: 3px solid var(--ee-focus-ring); outline-offset: 3px; } + @media (max-width: 900px) { + .learn-home__grid, .learning-item { grid-template-columns: 1fr; } + .learn-card__heading { display: grid; } + } + @media (max-width: 480px) { + .learn-home { padding: .75rem; } + .learn-summary { grid-template-columns: 1fr; } + .learn-home__quick-nav { grid-template-columns: 1fr; } + } `] }) export class LearnerPortalComponent implements OnInit { + private readonly analyticsService = inject(AnalyticsService); + private readonly badgesService = inject(BadgesService); private readonly coursesService = inject(CoursesService); + private readonly permissionsService = inject(PermissionsService); private readonly programsService = inject(ProgramsService); private readonly router = inject(Router); private readonly toastService = inject(ToastService); private readonly v2Platform = inject(V2PlatformService); + studentCourses: StudentCourseWithDetails[] = []; + activeStudentCourse?: StudentCourseWithDetails; + activeStudentCourseReason = ''; + activeContinuationTitle = 'Your next lesson is ready'; + activeContinuationEyebrow = 'Selected continuation'; + activeContinuationState: ContinuationGuidance['support_state'] = 'normal'; + activeReinforcementMessage = ''; + activePrompts: string[] = []; + availableCourses: Course[] = []; products: LearnerProduct[] = []; - programCount = 0; - certificateCount = 0; + programs: Program[] = []; + certifications: StudentCertification[] = []; + studentBadges: StudentBadge[] = []; + badgeProgress: StudentProgressResponse['badge_progress'] = []; + lessonsCompleted = 0; + coursesCompleted = 0; + + coursesLoading = true; + coursesLoadError = ''; + availableCoursesLoading = true; + availableCoursesLoadError = ''; + productsLoading = true; + progressLoading = true; + badgesLoading = true; + certificationsLoading = true; get enrolledProducts(): LearnerProduct[] { return this.products.filter(product => product.is_enrolled); @@ -145,64 +420,278 @@ export class LearnerPortalComponent implements OnInit { return this.products.filter(product => !product.is_enrolled); } - get nextProduct(): LearnerProduct | undefined { - return this.enrolledProducts[0] || this.availableProducts[0]; + get visibleAvailableProducts(): LearnerProduct[] { + return this.availableProducts.slice(0, 2); + } + + get visibleAvailableCourses(): Course[] { + return this.availableCourses.slice(0, Math.max(0, 4 - this.visibleAvailableProducts.length)); + } + + get availableLearningCount(): number { + return this.availableCourses.length + this.availableProducts.length; + } + + get inProgressCourseCount(): number { + return this.studentCourses.filter(course => course.status !== 'completed').length; + } + + get earnedAchievementCount(): number { + return this.studentBadges.length + this.certifications.length; + } + + get activeCourseProgress(): number { + return this.normalizedProgress(this.activeStudentCourse?.progress); + } + + get activeCourseStatusLabel(): string { + if (!this.activeStudentCourse) { + return ''; + } + return this.statusLabel(this.activeStudentCourse.status, this.activeStudentCourse.progress); + } + + get continuationTitle(): string { + return this.activeStudentCourse?.course.title || 'No active lesson yet'; + } + + get noContinuationTitle(): string { + if (this.studentCourses.length && this.studentCourses.every(course => course.status === 'completed')) { + return 'You completed your current learning'; + } + return 'Your next lesson will appear here'; + } + + get noContinuationBody(): string { + if (this.studentCourses.length && this.studentCourses.every(course => course.status === 'completed')) { + return 'You finished your current courses. Review your achievements or explore another course when you are ready.'; + } + return 'When a course is assigned or you start a course, your next learning step will appear here.'; } ngOnInit(): void { - this.v2Platform.getLearnerProducts().subscribe(products => { - this.products = products; + this.loadStudentCourses(); + this.loadAvailableProducts(); + this.loadProgress(); + this.loadBadges(); + this.loadProgramsAndCertifications(); + } + + loadStudentCourses(): void { + this.coursesLoading = true; + this.coursesLoadError = ''; + this.coursesService.getStudentCourses().subscribe({ + next: courses => { + this.studentCourses = courses; + this.resolveContinuationCourse(); + this.studentCourses.forEach(course => { + this.coursesService.getCourseProgress(course).subscribe(progress => { + course.progress = progress; + if (this.activeStudentCourse?.id === course.id) { + this.activeStudentCourse.progress = progress; + } + }); + }); + this.loadAvailableCourses(); + this.coursesLoading = false; + }, + error: () => { + this.studentCourses = []; + this.activeStudentCourse = undefined; + this.availableCourses = []; + this.availableCoursesLoading = false; + this.coursesLoadError = 'We could not load your courses. No progress was changed. Try again when your connection is ready.'; + this.coursesLoading = false; + }, }); + } - forkJoin({ - programs: this.programsService.getPrograms(), - certifications: this.programsService.getMyCertifications(), - }).subscribe({ - next: ({ programs, certifications }) => { - this.programCount = programs.length; - this.certificateCount = certifications.length; + loadAvailableCourses(): void { + this.availableCoursesLoading = true; + this.availableCoursesLoadError = ''; + this.coursesService.getCourses().subscribe({ + next: courses => { + const enrolledCourseIds = new Set(this.studentCourses.map(course => course.course_id)); + this.availableCourses = courses.filter(course => !enrolledCourseIds.has(course.id)); + this.availableCoursesLoading = false; }, error: () => { - this.programCount = 0; - this.certificateCount = 0; + this.availableCourses = []; + this.availableCoursesLoadError = 'We could not load the course catalog. Your current learning is unchanged.'; + this.availableCoursesLoading = false; }, }); } - continueProduct(product: LearnerProduct): void { - if (!product.course_id) { - this.toastService.show('This product type is not connected to the learner runtime yet.', 'error'); - return; - } - this.coursesService.startCourse({ course_id: product.course_id }).subscribe({ + loadAvailableProducts(): void { + this.productsLoading = true; + this.v2Platform.getLearnerProducts().subscribe({ + next: products => { + this.products = products; + this.productsLoading = false; + }, + error: () => { + this.products = []; + this.productsLoading = false; + }, + }); + } + + loadProgress(): void { + this.progressLoading = true; + this.analyticsService.getStudentProgress().subscribe({ + next: progress => { + this.badgeProgress = progress.badge_progress; + this.lessonsCompleted = progress.metrics.lessons_completed; + this.coursesCompleted = progress.metrics.courses_completed; + this.progressLoading = false; + }, + error: () => { + this.badgeProgress = []; + this.progressLoading = false; + }, + }); + } + + loadBadges(): void { + this.permissionsService.user$.pipe(take(1)).subscribe(user => { + const userId = user?.user_id; + if (!userId) { + this.badgesLoading = false; + return; + } + this.badgesLoading = true; + this.badgesService.getStudentBadges(userId).subscribe({ + next: badges => { + this.studentBadges = badges; + this.badgesLoading = false; + }, + error: () => { + this.studentBadges = []; + this.badgesLoading = false; + }, + }); + }); + } + + loadProgramsAndCertifications(): void { + this.programsService.getPrograms().subscribe({ + next: programs => this.programs = programs, + error: () => this.programs = [], + }); + + this.certificationsLoading = true; + this.programsService.getMyCertifications().subscribe({ + next: certifications => { + this.certifications = certifications; + this.certificationsLoading = false; + }, + error: () => { + this.certifications = []; + this.certificationsLoading = false; + }, + }); + } + + startCourse(course: StudentCourseWithDetails): void { + this.coursesService.startCourse({ course_id: course.course_id || course.course.id }).subscribe({ next: segment => { if (segment.delivery_state === 'governed_available' && segment.unit_progress_id) { this.router.navigate(['/learn/lesson', segment.unit_progress_id]); return; } - this.toastService.show(segment.detail || 'This product is not currently available for governed learner delivery.', 'error'); + this.toastService.show(segment.detail || 'This lesson is not ready yet. Try again later.', 'error'); }, - error: () => this.toastService.show('Unable to continue this product right now.', 'error'), + error: () => this.toastService.show('We could not start this lesson. Try again in a moment.', 'error'), }); } - enroll(product: LearnerProduct): void { + enrollInCourse(courseId: string): void { + this.coursesService.enrollInCourse(courseId).subscribe({ + next: () => { + this.toastService.show('Course added to your learning.', 'success'); + this.loadStudentCourses(); + }, + error: () => this.toastService.show('We could not add this course. You may already have it.', 'error'), + }); + } + + enrollProduct(product: LearnerProduct): void { if (!product.course_id) { - this.toastService.show('Enrollment is only available for course-backed products right now.', 'error'); + this.toastService.show('This learning item is not connected to course lessons yet.', 'error'); return; } - this.coursesService.enrollInCourse(product.course_id).subscribe({ - next: () => { - this.toastService.show('Enrollment successful.', 'success'); - this.v2Platform.getLearnerProducts().subscribe(products => { - this.products = products; - }); - }, - error: () => this.toastService.show('Enrollment failed. You may already be enrolled.', 'error'), + this.enrollInCourse(product.course_id); + } + + retryCourseLoad(): void { + this.loadStudentCourses(); + } + + retryAvailableCoursesLoad(): void { + this.loadAvailableCourses(); + this.loadAvailableProducts(); + } + + goToCourses(): void { + this.router.navigate(['/learn/products']); + } + + getCourseActionLabel(course: StudentCourseWithDetails): string { + return (course.progress ?? 0) > 0 || course.status === 'active' || course.status === 'in_progress' + ? 'Resume lesson' + : 'Start lesson'; + } + + normalizedProgress(value: number | undefined): number { + return Math.max(0, Math.min(100, value || 0)); + } + + statusLabel(status: string | undefined, progress: number | undefined): string { + if (status === 'completed') { + return 'Complete'; + } + if (this.normalizedProgress(progress) > 0 || status === 'active' || status === 'in_progress') { + return 'In progress'; + } + return 'Ready'; + } + + private resolveContinuationCourse(): void { + const inProgressStatuses = new Set(['active', 'in_progress', 'in-progress']); + const orderedCourses = [...this.studentCourses].sort((left, right) => { + const leftDate = Date.parse(left.enrolled_on || '') || 0; + const rightDate = Date.parse(right.enrolled_on || '') || 0; + if (leftDate !== rightDate) { + return leftDate - rightDate; + } + return left.course.title.localeCompare(right.course.title); }); + + this.activeStudentCourse = orderedCourses.find(course => + course.status !== 'completed' + && (inProgressStatuses.has((course.status || '').toLowerCase()) || (course.progress ?? 0) > 0) + ) || orderedCourses.find(course => course.status !== 'completed'); + + this.applyContinuationGuidance(this.activeStudentCourse?.continuation_guidance); } - label(value: string): string { - return value.replace(/_/g, ' ').replace(/\b\w/g, char => char.toUpperCase()); + private applyContinuationGuidance(guidance: ContinuationGuidance | null | undefined): void { + this.activeStudentCourseReason = guidance?.learner_message || 'Continue from the next approved lesson in this course.'; + this.activeContinuationTitle = guidance?.learner_title || 'Your next lesson is ready'; + this.activeContinuationState = guidance?.support_state || 'normal'; + this.activeReinforcementMessage = guidance?.reinforcement_message || ''; + this.activePrompts = + guidance?.support_state === 'remediation' + ? guidance.review_prompts || [] + : guidance?.support_state === 'enrichment' + ? guidance.extension_prompts || [] + : []; + this.activeContinuationEyebrow = + guidance?.support_state === 'remediation' + ? 'Helpful review' + : guidance?.support_state === 'enrichment' + ? 'Optional deeper learning' + : 'Recommended next step'; } } diff --git a/frontend/src/app/pages/learner-portal/learner-products.component.ts b/frontend/src/app/pages/learner-portal/learner-products.component.ts index e015dfd..8203a80 100644 --- a/frontend/src/app/pages/learner-portal/learner-products.component.ts +++ b/frontend/src/app/pages/learner-portal/learner-products.component.ts @@ -1,6 +1,6 @@ import { CommonModule } from '@angular/common'; import { Component, OnInit, inject } from '@angular/core'; -import { Router } from '@angular/router'; +import { Router, RouterModule } from '@angular/router'; import { EchoButtonComponent } from '../../components/echo-button/echo-button.component'; import { LearnerProduct } from '../../models/v2-platform.model'; import { CoursesService } from '../../services/courses.service'; @@ -10,33 +10,33 @@ import { V2PlatformService } from '../../services/v2-platform.service'; @Component({ selector: 'app-learner-products', standalone: true, - imports: [CommonModule, EchoButtonComponent], + imports: [CommonModule, RouterModule, EchoButtonComponent], template: `
-

Products

-

Available and enrolled products

- Course-backed products use existing enrollments, progress, and governed lesson delivery. +

Courses

+

Your courses and available learning

+ Start or resume course-backed learning that is ready for your student account.
-

Enrolled

+

Current courses

-

No enrolled products yet.

+

No current courses yet. When you start a course, it will appear here.

-

Available

+

Available learning

-

No approved or published product wrappers are available right now.

+

No additional courses are ready for you right now.

@@ -48,14 +48,14 @@ import { V2PlatformService } from '../../services/v2-platform.service';
Access
-
{{ product.is_enrolled ? 'Enrolled' : 'Available' }}
+
{{ product.is_enrolled ? 'In your learning' : 'Available' }}
-
Wrapper
-
{{ label(product.product_status || product.source) }}
+
Status
+
{{ product.is_enrolled ? 'Ready to continue' : 'Ready to start' }}
-
Governance
+
Learning access
{{ product.learner_visibility }}
@@ -65,29 +65,31 @@ import { V2PlatformService } from '../../services/v2-platform.service'; label="Continue" (click)="continueProduct(product)" > + View course path -

Access granted. Runtime delivery is not connected yet.

+

This learning item is visible to you, but course lessons are not connected yet.

`, styles: [` - .learner-products { color: #102033; display: grid; gap: 1rem; padding: clamp(1rem, 3vw, 2rem); } - header, .queue { background: #fff; border: 1px solid #d8e1ea; border-radius: 8px; box-shadow: 0 14px 32px rgba(16,32,51,.08); padding: clamp(1rem, 3vw, 1.5rem); } - header p { color: #0f766e; font-size: .76rem; font-weight: 900; letter-spacing: .12em; margin: 0 0 .35rem; text-transform: uppercase; } + .learner-products { color: var(--ee-text); display: grid; gap: 1rem; padding: clamp(1rem, 3vw, 2rem); } + header, .queue { background: var(--ee-surface); border: 1px solid var(--ee-border); border-radius: var(--ee-radius-lg, 8px); box-shadow: var(--ee-shadow-soft); padding: clamp(1rem, 3vw, 1.5rem); } + header p { color: var(--ee-text-muted); font-size: .76rem; font-weight: 900; letter-spacing: .12em; margin: 0 0 .35rem; text-transform: uppercase; } h1, h2, h3 { letter-spacing: 0; margin: 0; } h1 { font-size: clamp(2rem, 5vw, 3rem); line-height: 1; } - header span, .card p, .empty, dd { color: #526273; } + header span, .card p, .empty, dd { color: var(--ee-text-soft); } .queue { display: grid; gap: .9rem; } .grid { display: grid; gap: .85rem; grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); } - .card { border: 1px solid #d8e1ea; border-radius: 8px; display: grid; gap: .75rem; padding: 1rem; } + .card { background: var(--ee-surface-muted, #f8fafc); border: 1px solid var(--ee-border); border-radius: var(--ee-radius, 6px); display: grid; gap: .75rem; padding: 1rem; } + .card__link { color: var(--ee-action-primary); font-weight: 900; text-decoration: underline; text-underline-offset: .2em; } dl { display: grid; gap: .5rem; margin: 0; } - dt { color: #334155; font-size: .75rem; font-weight: 900; text-transform: uppercase; } + dt { color: var(--ee-text); font-size: .75rem; font-weight: 900; text-transform: uppercase; } dd { margin: .15rem 0 0; } `] }) @@ -113,7 +115,7 @@ export class LearnerProductsComponent implements OnInit { continueProduct(product: LearnerProduct): void { if (!product.course_id) { - this.toastService.show('This product type is not connected to the learner runtime yet.', 'error'); + this.toastService.show('This learning item is not connected to course lessons yet.', 'error'); return; } this.coursesService.startCourse({ course_id: product.course_id }).subscribe({ @@ -122,23 +124,23 @@ export class LearnerProductsComponent implements OnInit { this.router.navigate(['/learn/lesson', segment.unit_progress_id]); return; } - this.toastService.show(segment.detail || 'This product is not currently available for governed learner delivery.', 'error'); + this.toastService.show(segment.detail || 'This course is not ready for learning right now.', 'error'); }, - error: () => this.toastService.show('Unable to continue this product right now.', 'error'), + error: () => this.toastService.show('Unable to continue this course right now.', 'error'), }); } enroll(product: LearnerProduct): void { if (!product.course_id) { - this.toastService.show('Enrollment is only available for course-backed products right now.', 'error'); + this.toastService.show('Starting is only available for course-backed learning right now.', 'error'); return; } this.coursesService.enrollInCourse(product.course_id).subscribe({ next: () => { - this.toastService.show('Enrollment successful.', 'success'); + this.toastService.show('Course added to your learning.', 'success'); this.loadProducts(); }, - error: () => this.toastService.show('Enrollment failed. You may already be enrolled.', 'error'), + error: () => this.toastService.show('We could not add this course. You may already have it.', 'error'), }); } diff --git a/frontend/src/app/pages/learner-portal/learner-resources.component.ts b/frontend/src/app/pages/learner-portal/learner-resources.component.ts index 789bff3..06d4af1 100644 --- a/frontend/src/app/pages/learner-portal/learner-resources.component.ts +++ b/frontend/src/app/pages/learner-portal/learner-resources.component.ts @@ -11,25 +11,25 @@ import { RouterModule } from '@angular/router';

Resources

Resources and downloads

- Downloadable artifacts, documentation packs, and knowledge hub resources are planned for a later phase. + Shared learner resources will appear here when they are ready for student use.

No learner resources yet

-

Course lessons, progress, certificates, and learning paths remain available through the existing governed runtime.

- Back to products +

Your lessons, progress, achievements, and learning paths are still available from Learn.

+ Back to courses
`, styles: [` - .resources { color: #102033; display: grid; gap: 1rem; padding: clamp(1rem, 3vw, 2rem); } - header, article { background: #fff; border: 1px solid #d8e1ea; border-radius: 8px; box-shadow: 0 14px 32px rgba(16,32,51,.08); padding: clamp(1rem, 3vw, 1.5rem); } - header p { color: #0f766e; font-size: .76rem; font-weight: 900; letter-spacing: .12em; margin: 0 0 .35rem; text-transform: uppercase; } + .resources { color: var(--ee-text); display: grid; gap: 1rem; padding: clamp(1rem, 3vw, 2rem); } + header, article { background: var(--ee-surface); border: 1px solid var(--ee-border); border-radius: var(--ee-radius-lg, 8px); box-shadow: var(--ee-shadow-soft); padding: clamp(1rem, 3vw, 1.5rem); } + header p { color: var(--ee-text-muted); font-size: .76rem; font-weight: 900; letter-spacing: .12em; margin: 0 0 .35rem; text-transform: uppercase; } h1, h2 { letter-spacing: 0; margin: 0; } h1 { font-size: clamp(2rem, 5vw, 3rem); line-height: 1; } - header span, article p { color: #526273; } + header span, article p { color: var(--ee-text-soft); } article { display: grid; gap: .75rem; } - a { background: #102033; border-radius: 6px; color: #fff; font-weight: 900; padding: .65rem .85rem; text-decoration: none; width: fit-content; } - a:focus-visible { outline: 3px solid rgba(15,118,110,.25); outline-offset: 3px; } + a { background: var(--ee-action-primary); border-radius: var(--ee-radius, 6px); color: var(--ee-action-primary-contrast, #fff); font-weight: 900; padding: .65rem .85rem; text-decoration: none; width: fit-content; } + a:focus-visible { outline: 3px solid var(--ee-focus-ring); outline-offset: 3px; } `] }) export class LearnerResourcesComponent {} diff --git a/frontend/src/app/pages/learner-portal/student-course-overview.component.spec.ts b/frontend/src/app/pages/learner-portal/student-course-overview.component.spec.ts new file mode 100644 index 0000000..85c1c82 --- /dev/null +++ b/frontend/src/app/pages/learner-portal/student-course-overview.component.spec.ts @@ -0,0 +1,165 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { of, throwError } from 'rxjs'; + +import { StudentCourseOverviewComponent } from './student-course-overview.component'; +import { CoursesService } from '../../services/courses.service'; +import { ToastService } from '../../services/toast.service'; +import { StudentCourseWithDetails } from '../../models/student-course-with-details.model'; + +const course = { + id: 'course-1', + title: 'Introduction to Africa', + description: 'Explore geography, history, and community knowledge.', + learning_objectives: 'Locate major regions and explain how communities shape history.', + created_at: new Date(), + units: [ + { + id: 'unit-1', + title: 'Place and People', + order: 1, + lessons: [ + { + id: 'lesson-1', + title: 'Mapping Africa', + objective: 'Read a map with care.', + order: 1, + duration_minutes: 15, + activities: [{ id: 'activity-1', title: 'Map check', type: 'quiz', content: '{}' }], + }, + { + id: 'lesson-2', + title: 'Communities and History', + order: 2, + activities: [], + }, + ], + }, + ], +}; + +function enrolled(overrides: Partial = {}): StudentCourseWithDetails { + return { + id: 'student-course-1', + student_id: 'student-1', + course_id: 'course-1', + enrolled_on: '2026-01-01T00:00:00Z', + status: 'active', + course, + unit_progress_id: 'unit-progress-1', + progress: 0, + ...overrides, + }; +} + +class MockCoursesService { + courseResponse = of(course); + studentCoursesResponse = of([enrolled()]); + progressResponse = of(25); + segmentResponse = of({ + delivery_state: 'governed_available', + lesson_id: 'lesson-1', + unit_progress_id: 'unit-progress-1', + }); + + getCourseById = jasmine.createSpy('getCourseById').and.callFake(() => this.courseResponse); + getStudentCourses = jasmine.createSpy('getStudentCourses').and.callFake(() => this.studentCoursesResponse); + getCourseProgress = jasmine.createSpy('getCourseProgress').and.callFake(() => this.progressResponse); + getCurrentSegment = jasmine.createSpy('getCurrentSegment').and.callFake(() => this.segmentResponse); + enrollInCourse = jasmine.createSpy('enrollInCourse').and.returnValue(of({})); + startCourse = jasmine.createSpy('startCourse').and.callFake(() => this.segmentResponse); +} + +class MockToastService { + show = jasmine.createSpy('show'); +} + +describe('StudentCourseOverviewComponent', () => { + let fixture: ComponentFixture; + let component: StudentCourseOverviewComponent; + let coursesService: MockCoursesService; + let router: Router; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [RouterTestingModule, StudentCourseOverviewComponent], + providers: [ + { provide: CoursesService, useClass: MockCoursesService }, + { provide: ToastService, useClass: MockToastService }, + { + provide: ActivatedRoute, + useValue: { + snapshot: { + paramMap: { + get: () => 'course-1', + }, + }, + }, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(StudentCourseOverviewComponent); + component = fixture.componentInstance; + coursesService = TestBed.inject(CoursesService) as unknown as MockCoursesService; + router = TestBed.inject(Router); + spyOn(router, 'navigate').and.resolveTo(true); + fixture.detectChanges(); + }); + + it('loads the authorized course overview with unit hierarchy and current lesson state', () => { + const compiled = fixture.nativeElement as HTMLElement; + + expect(coursesService.getCourseById).toHaveBeenCalledWith('course-1'); + expect(compiled.textContent).toContain('Introduction to Africa'); + expect(compiled.textContent).toContain('Place and People'); + expect(compiled.textContent).toContain('Mapping Africa'); + expect(compiled.textContent).toContain('In progress'); + expect(compiled.textContent).toContain('Locked'); + expect(component.curriculumUnits[0].lessons[0].canOpen).toBeTrue(); + expect(component.curriculumUnits[0].lessons[1].canOpen).toBeFalse(); + }); + + it('resumes an in-progress course through the governed start-course endpoint', () => { + component.performPrimaryAction(); + + expect(coursesService.startCourse).toHaveBeenCalledWith({ course_id: 'course-1' }); + expect(router.navigate).toHaveBeenCalledWith(['/learn/lesson', 'unit-progress-1']); + }); + + it('enrolls before starting a not-started course', () => { + coursesService.studentCoursesResponse = of([]); + fixture = TestBed.createComponent(StudentCourseOverviewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + coursesService.studentCoursesResponse = of([enrolled()]); + component.performPrimaryAction(); + + expect(coursesService.enrollInCourse).toHaveBeenCalledWith('course-1'); + expect(coursesService.startCourse).toHaveBeenCalledWith({ course_id: 'course-1' }); + }); + + it('does not mark completed courses incomplete or start another segment', () => { + coursesService.studentCoursesResponse = of([enrolled({ status: 'completed', progress: 100 })]); + fixture = TestBed.createComponent(StudentCourseOverviewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + component.performPrimaryAction(); + + expect(coursesService.startCourse).not.toHaveBeenCalled(); + expect(component.actionMessage).toContain('complete'); + }); + + it('renders invalid course id failures without changing progress', () => { + coursesService.getCourseById.and.returnValue(throwError(() => ({ status: 400 }))); + fixture = TestBed.createComponent(StudentCourseOverviewComponent); + fixture.detectChanges(); + + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.textContent).toContain('This course link is not valid'); + expect(compiled.querySelector('[data-echo-state="error"]')).not.toBeNull(); + }); +}); diff --git a/frontend/src/app/pages/learner-portal/student-course-overview.component.ts b/frontend/src/app/pages/learner-portal/student-course-overview.component.ts new file mode 100644 index 0000000..2acd857 --- /dev/null +++ b/frontend/src/app/pages/learner-portal/student-course-overview.component.ts @@ -0,0 +1,603 @@ +import { CommonModule } from '@angular/common'; +import { Component, OnInit, inject } from '@angular/core'; +import { ActivatedRoute, Router, RouterModule } from '@angular/router'; +import { forkJoin, of } from 'rxjs'; +import { catchError, finalize } from 'rxjs/operators'; + +import { + StudentCurriculumComponent, + StudentCurriculumLesson, + StudentCurriculumUnit, +} from '../../components/student-curriculum/student-curriculum.component'; +import { EchoButtonComponent } from '../../components/echo-button/echo-button.component'; +import { EchoLoadingStateComponent } from '../../components/echo-loading-state/echo-loading-state.component'; +import { EchoStatePanelComponent } from '../../components/echo-state-panel/echo-state-panel.component'; +import { Course } from '../../models/course'; +import { Lesson } from '../../models/lesson'; +import { SegmentResponse } from '../../models/segment-response.model'; +import { StudentCourseWithDetails } from '../../models/student-course-with-details.model'; +import { Unit } from '../../models/unit'; +import { CoursesService } from '../../services/courses.service'; +import { ToastService } from '../../services/toast.service'; + +@Component({ + selector: 'app-student-course-overview', + standalone: true, + imports: [ + CommonModule, + RouterModule, + StudentCurriculumComponent, + EchoButtonComponent, + EchoLoadingStateComponent, + EchoStatePanelComponent, + ], + template: ` +
+ Back to Learn + + + + + + +
+
+

Course

+

{{ course.title }}

+

+ {{ course.description || 'This course is ready for student learning.' }} +

+
+ {{ enrollmentLabel }} + {{ lessonCount }} lesson{{ lessonCount === 1 ? '' : 's' }} + {{ unitCount }} unit{{ unitCount === 1 ? '' : 's' }} +
+
+ + +
+ +
+

Learning goals

+

What this course helps you learn

+

{{ learningObjectives }}

+
+ + + +
+

Completion outcome

+

When you finish

+

+ EchoEd will use the existing course progress, assessment, badge, and certificate systems to record supported milestones. + Any badges or certificates you earn appear in Achievements. +

+ View achievements and certificates +
+
+
+ `, + styles: [` + .student-course { + color: var(--ee-text); + display: grid; + gap: var(--ee-space-5, 1.25rem); + padding: clamp(1rem, 3vw, 2rem); + } + + .student-course__back, + .student-course__section a { + color: var(--ee-action-primary); + font-weight: 900; + text-decoration: underline; + text-underline-offset: 0.2em; + } + + .student-course__hero, + .student-course__section { + background: var(--ee-surface); + border: 1px solid var(--ee-border); + border-radius: var(--ee-radius-lg, 8px); + box-shadow: var(--ee-shadow-soft); + padding: clamp(1rem, 3vw, 1.5rem); + } + + .student-course__hero { + display: grid; + gap: clamp(1rem, 3vw, 1.5rem); + grid-template-columns: minmax(0, 1fr) minmax(18rem, 0.38fr); + } + + .student-course__identity, + .student-course__section, + .student-course__action { + display: grid; + gap: 0.8rem; + } + + .student-course__action { + align-content: start; + background: var(--ee-surface-muted, #f8fafc); + border: 1px solid var(--ee-border); + border-radius: var(--ee-radius, 6px); + padding: 1rem; + } + + .student-course__eyebrow { + color: var(--ee-text-muted); + font-size: 0.76rem; + font-weight: 900; + letter-spacing: 0.12em; + margin: 0; + text-transform: uppercase; + } + + h1, + h2, + p { + letter-spacing: 0; + margin: 0; + } + + h1 { + font-size: clamp(2.1rem, 6vw, 4rem); + line-height: 1; + max-width: 13ch; + } + + h2 { + font-size: clamp(1.2rem, 3vw, 1.6rem); + } + + .student-course__description, + .student-course__section p, + .student-course__action p, + .student-course__action-message { + color: var(--ee-text-soft); + line-height: 1.65; + } + + .student-course__tags { + display: flex; + flex-wrap: wrap; + gap: 0.55rem; + } + + .student-course__tags span { + background: var(--ee-surface-muted, #f8fafc); + border: 1px solid var(--ee-border); + border-radius: 999px; + display: inline-flex; + font-size: 0.8rem; + font-weight: 900; + padding: 0.35rem 0.65rem; + } + + .student-course__progress { + display: grid; + gap: 0.55rem; + } + + .student-course__progress > div:first-child { + align-items: center; + display: flex; + justify-content: space-between; + } + + .student-course__progress span { + color: var(--ee-text-soft); + font-size: 0.82rem; + font-weight: 800; + } + + .student-course__progress strong { + color: var(--ee-action-primary); + } + + .student-course__track { + background: var(--ee-border); + border-radius: 999px; + height: 0.72rem; + overflow: hidden; + } + + .student-course__bar { + background: var(--ee-action-primary); + border-radius: inherit; + height: 100%; + min-width: 0.15rem; + } + + @media (max-width: 900px) { + .student-course__hero { + grid-template-columns: 1fr; + } + } + + @media (max-width: 480px) { + .student-course { + padding: 0.75rem; + } + } + `], +}) +export class StudentCourseOverviewComponent implements OnInit { + private readonly coursesService = inject(CoursesService); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly toastService = inject(ToastService); + + courseId = ''; + course?: Course; + enrolledCourse?: StudentCourseWithDetails; + currentSegment?: SegmentResponse; + loading = true; + loadErrorMessage = ''; + actionPending = false; + actionMessage = ''; + courseProgress = 0; + curriculumUnits: StudentCurriculumUnit[] = []; + + get unitCount(): number { + return this.orderedUnits.length; + } + + get lessonCount(): number { + return this.orderedUnits.reduce((count, unit) => count + this.orderedLessons(unit).length, 0); + } + + get learningObjectives(): string { + return this.course?.learning_objectives || ''; + } + + get enrollmentLabel(): string { + if (!this.enrolledCourse) { + return 'Available to start'; + } + if (this.enrolledCourse.status === 'completed') { + return 'Completed'; + } + return this.courseProgress > 0 ? 'In progress' : 'Ready'; + } + + get primaryActionLabel(): string { + if (this.actionPending) { + return 'Preparing...'; + } + if (!this.enrolledCourse) { + return 'Start course'; + } + if (this.enrolledCourse.status === 'completed') { + return 'Review course'; + } + if (this.currentSegment?.delivery_state === 'governed_available') { + return this.courseProgress > 0 ? 'Resume lesson' : 'Start lesson'; + } + return 'Check course status'; + } + + get primaryActionTitle(): string { + if (!this.enrolledCourse) { + return 'Start when you are ready'; + } + if (this.enrolledCourse.status === 'completed') { + return 'Course completed'; + } + if (this.currentSegment?.delivery_state === 'governed_available') { + return 'Your next lesson is ready'; + } + return 'Course status'; + } + + get primaryActionBody(): string { + if (!this.enrolledCourse) { + return 'Starting adds this course to your learning and opens the next governed lesson when available.'; + } + if (this.enrolledCourse.status === 'completed') { + return 'Review the course path and visit Achievements to see supported badges or certificates.'; + } + if (this.currentSegment?.delivery_state === 'governed_available') { + return 'Continue through the next approved lesson in your course path.'; + } + return this.currentSegment?.detail || 'The next lesson is not available for learner delivery right now.'; + } + + get primaryActionAriaLabel(): string { + return `${this.primaryActionLabel}: ${this.course?.title || 'course'}`; + } + + get primaryActionDisabled(): boolean { + return this.enrolledCourse?.status === 'completed' ? false : false; + } + + get curriculumSummaryLabel(): string { + if (!this.lessonCount) { + return 'No learner lessons listed'; + } + return `${this.unitCount} unit${this.unitCount === 1 ? '' : 's'}, ${this.lessonCount} lesson${this.lessonCount === 1 ? '' : 's'}`; + } + + private get orderedUnits(): Unit[] { + return [...(this.course?.units || [])].sort((left, right) => { + const leftOrder = left.order ?? Number.MAX_SAFE_INTEGER; + const rightOrder = right.order ?? Number.MAX_SAFE_INTEGER; + if (leftOrder !== rightOrder) { + return leftOrder - rightOrder; + } + return left.title.localeCompare(right.title); + }); + } + + ngOnInit(): void { + this.courseId = this.route.snapshot.paramMap.get('courseId') || ''; + this.loadCourse(); + } + + loadCourse(): void { + if (!this.courseId) { + this.loading = false; + this.loadErrorMessage = 'This course link is missing a course id.'; + return; + } + + this.loading = true; + this.loadErrorMessage = ''; + this.actionMessage = ''; + + forkJoin({ + course: this.coursesService.getCourseById(this.courseId), + studentCourses: this.coursesService.getStudentCourses().pipe(catchError(() => of([] as StudentCourseWithDetails[]))), + }).subscribe({ + next: ({ course, studentCourses }) => { + this.course = course as unknown as Course; + this.enrolledCourse = studentCourses.find(studentCourse => studentCourse.course_id === this.courseId); + this.courseProgress = 0; + this.currentSegment = undefined; + this.curriculumUnits = this.buildCurriculumUnits(); + + if (!this.enrolledCourse) { + this.loading = false; + return; + } + + forkJoin({ + progress: this.coursesService.getCourseProgress(this.enrolledCourse), + segment: this.enrolledCourse.unit_progress_id + ? this.coursesService.getCurrentSegment(this.enrolledCourse.unit_progress_id).pipe(catchError(() => of(undefined))) + : of(undefined), + }).subscribe({ + next: ({ progress, segment }) => { + this.courseProgress = this.normalizeProgress(progress); + this.currentSegment = segment; + this.curriculumUnits = this.buildCurriculumUnits(); + this.loading = false; + }, + error: () => { + this.curriculumUnits = this.buildCurriculumUnits(); + this.loading = false; + }, + }); + }, + error: (error) => { + this.course = undefined; + this.enrolledCourse = undefined; + this.currentSegment = undefined; + this.curriculumUnits = []; + this.loading = false; + this.loadErrorMessage = error?.status === 400 + ? 'This course link is not valid.' + : error?.status === 404 + ? 'This course could not be found.' + : 'We could not load this course. Your progress was not changed.'; + }, + }); + } + + performPrimaryAction(): void { + if (!this.course) { + return; + } + + if (this.enrolledCourse?.status === 'completed') { + this.actionMessage = 'This course is complete. Review the course path below or open Achievements.'; + return; + } + + if (!this.enrolledCourse) { + this.actionPending = true; + this.actionMessage = 'Adding this course to your learning...'; + this.coursesService.enrollInCourse(this.course.id).subscribe({ + next: () => { + this.actionMessage = 'Course added. Preparing your first lesson...'; + this.loadAndStartEnrolledCourse(); + }, + error: () => { + this.actionPending = false; + this.actionMessage = 'We could not add this course. It may already be in your learning.'; + this.toastService.show('Unable to add this course right now.', 'error'); + }, + }); + return; + } + + this.startEnrolledCourse(this.enrolledCourse); + } + + openCurriculumLesson(lesson: StudentCurriculumLesson): void { + if (!lesson.canOpen || !this.enrolledCourse) { + this.actionMessage = lesson.stateDescription; + return; + } + this.startEnrolledCourse(this.enrolledCourse); + } + + private loadAndStartEnrolledCourse(): void { + this.coursesService.getStudentCourses().subscribe({ + next: courses => { + this.enrolledCourse = courses.find(course => course.course_id === this.courseId); + if (!this.enrolledCourse) { + this.actionPending = false; + this.actionMessage = 'The course was added, but we could not find the new learning record yet. Return to Learn and try again.'; + return; + } + this.startEnrolledCourse(this.enrolledCourse); + }, + error: () => { + this.actionPending = false; + this.actionMessage = 'The course was added, but we could not refresh the learning record.'; + }, + }); + } + + private startEnrolledCourse(course: StudentCourseWithDetails): void { + this.actionPending = true; + this.actionMessage = 'Preparing your governed lesson...'; + this.coursesService.startCourse({ course_id: course.course_id || course.course.id }).pipe( + finalize(() => { + this.actionPending = false; + }), + ).subscribe({ + next: segment => { + if (segment.delivery_state === 'governed_available' && segment.unit_progress_id) { + this.router.navigate(['/learn/lesson', segment.unit_progress_id]); + return; + } + this.currentSegment = segment; + this.curriculumUnits = this.buildCurriculumUnits(); + this.actionMessage = segment.detail || 'This course does not have a learner-ready lesson right now.'; + }, + error: error => { + this.actionMessage = error?.error?.detail || 'We could not start this course right now. Try again in a moment.'; + this.toastService.show('Unable to start this course right now.', 'error'); + }, + }); + } + + private buildCurriculumUnits(): StudentCurriculumUnit[] { + return this.orderedUnits.map(unit => { + const lessons = this.orderedLessons(unit).map(lesson => this.mapLesson(lesson)); + const currentLessonCount = lessons.filter(lesson => lesson.state === 'current').length; + const completedLessonCount = lessons.filter(lesson => lesson.state === 'complete').length; + const progressLabel = this.enrolledCourse?.status === 'completed' + ? `${lessons.length} of ${lessons.length} complete` + : currentLessonCount + ? 'Current unit' + : completedLessonCount + ? `${completedLessonCount} complete` + : 'Included in course'; + + return { + id: unit.id, + title: unit.title, + order: unit.order, + progressLabel, + lessons, + }; + }); + } + + private mapLesson(lesson: Lesson): StudentCurriculumLesson { + if (this.enrolledCourse?.status === 'completed') { + return this.lessonState(lesson, 'complete', 'Complete', 'This lesson is part of your completed course.', false); + } + + if (!this.enrolledCourse) { + return this.lessonState(lesson, 'included', 'Included', 'Start the course to open the next governed lesson.', false); + } + + if (this.currentSegment?.delivery_state === 'governed_available' && this.currentSegment.lesson_id === lesson.id) { + return this.lessonState(lesson, 'current', this.courseProgress > 0 ? 'In progress' : 'Up next', 'This is the next governed lesson available to you.', true); + } + + if (this.currentSegment?.delivery_state && this.currentSegment.delivery_state !== 'governed_available') { + return this.lessonState(lesson, 'unavailable', 'Unavailable', this.currentSegment.detail || 'This lesson path is not available right now.', false); + } + + return this.lessonState(lesson, 'locked', 'Locked', 'Use the course action to open the next lesson when it becomes available.', false); + } + + private lessonState( + lesson: Lesson, + state: StudentCurriculumLesson['state'], + stateLabel: string, + stateDescription: string, + canOpen: boolean, + ): StudentCurriculumLesson { + return { + id: lesson.id, + title: lesson.title, + objective: lesson.objective || lesson.learning_objectives, + durationMinutes: lesson.duration_minutes, + activityCount: lesson.activities?.length || 0, + state, + stateLabel, + stateDescription, + canOpen, + }; + } + + private orderedLessons(unit: Unit): Lesson[] { + return [...(unit.lessons || [])].sort((left, right) => { + const leftOrder = left.order ?? Number.MAX_SAFE_INTEGER; + const rightOrder = right.order ?? Number.MAX_SAFE_INTEGER; + if (leftOrder !== rightOrder) { + return leftOrder - rightOrder; + } + return left.title.localeCompare(right.title); + }); + } + + private normalizeProgress(progress: number | undefined): number { + return Math.max(0, Math.min(100, progress || 0)); + } +} diff --git a/frontend/src/app/pages/lesson-view.component.html b/frontend/src/app/pages/lesson-view.component.html index d26f2c2..2aeadb2 100644 --- a/frontend/src/app/pages/lesson-view.component.html +++ b/frontend/src/app/pages/lesson-view.component.html @@ -4,7 +4,13 @@

Immersive Lesson

{{ lesson?.title || 'Preparing your next lesson' }}

-

Step through each governed activity with clear focus and consistent learner guidance.

+

+ {{ courseContextTitle }} + / + {{ unitContextTitle }} + / {{ lessonPositionLabel }} + Step through each governed activity with clear focus and consistent learner guidance. +

@@ -18,17 +24,27 @@

{{ lesson?.title {{ lesson.duration_minutes }} min

- +
Active segment + {{ unitContextTitle }} + {{ lessonPositionLabel }} Guided reading & activities - Safe to return to dashboard at any time + Safe to return to Learn at any time Demo preview
+

+ Saving your lesson progress... +

+ + + {{ lesson?.title eyebrow="Governed lesson state" [title]="governedStateTitle" [body]="governedStateBody" - actionLabel="Return to dashboard" - actionAriaLabel="Return to the learner dashboard" + actionLabel="Return to Learn" + actionAriaLabel="Return to Learn" (action)="returnToDashboard()" > @@ -75,9 +91,9 @@

{{ lesson?.title diff --git a/frontend/src/app/pages/lesson-view.component.scss b/frontend/src/app/pages/lesson-view.component.scss index 557b26d..c18d4a6 100644 --- a/frontend/src/app/pages/lesson-view.component.scss +++ b/frontend/src/app/pages/lesson-view.component.scss @@ -6,3 +6,18 @@ outline: none; box-shadow: 0 0 0 3px rgba(14, 116, 144, 0.18); } + +.lesson-runtime__save-status { + background: var(--ee-status-info-bg, #e0f2fe); + border: 1px solid var(--ee-status-info-border, #38bdf8); + border-radius: var(--ee-radius, 6px); + color: var(--ee-text); + font-weight: 700; + margin: 0 0 1rem; + padding: 0.75rem 1rem; +} + +.lesson-runtime__save-status--error { + background: var(--ee-status-danger-bg, #fee2e2); + border-color: var(--ee-status-danger-border, #ef4444); +} diff --git a/frontend/src/app/pages/lesson-view.component.spec.ts b/frontend/src/app/pages/lesson-view.component.spec.ts index 0e9f0ce..3fa16b2 100644 --- a/frontend/src/app/pages/lesson-view.component.spec.ts +++ b/frontend/src/app/pages/lesson-view.component.spec.ts @@ -19,7 +19,7 @@ class MockCoursesService { markSegmentCompleted = jasmine.createSpy('markSegmentCompleted'); getCourses = jasmine.createSpy('getCourses'); getCourseById = jasmine.createSpy('getCourseById'); - getStudentCourses = jasmine.createSpy('getStudentCourses'); + getStudentCourses = jasmine.createSpy('getStudentCourses').and.returnValue(of([])); } describe('LessonViewComponent', () => { @@ -54,7 +54,7 @@ describe('LessonViewComponent', () => { expect(compiled.querySelector('[data-echo-state="blocked"]')).not.toBeNull(); expect(compiled.textContent).toContain('This lesson is waiting for governed approval.'); - expect(compiled.textContent).toContain('Return to dashboard'); + expect(compiled.textContent).toContain('Return to Learn'); }); it('renders the canonical loading state while the lesson segment is resolving', async () => { @@ -71,7 +71,7 @@ describe('LessonViewComponent', () => { expect(compiled.textContent).toContain('Preparing lesson delivery'); }); - it('preserves the learner-safe exit control label used by the smoke flow', () => { + it('preserves the learner-safe return control used by the smoke flow', () => { const coursesService = TestBed.inject(CoursesService) as unknown as MockCoursesService; coursesService.getCurrentSegment.and.returnValue(of({ delivery_state: 'governed_available', @@ -89,11 +89,47 @@ describe('LessonViewComponent', () => { fixture.detectChanges(); const exitButton = Array.from(fixture.nativeElement.querySelectorAll('button')) - .find((button) => (button as HTMLButtonElement).getAttribute('aria-label') === 'Exit lesson and return to dashboard') as HTMLButtonElement; + .find((button) => (button as HTMLButtonElement).getAttribute('aria-label') === 'Return to the course overview') as HTMLButtonElement; expect(exitButton).not.toBeNull(); }); + it('shows course and unit context when the current lesson is found in student courses', () => { + const coursesService = TestBed.inject(CoursesService) as unknown as MockCoursesService; + coursesService.getCurrentSegment.and.returnValue(of({ + delivery_state: 'governed_available', + lesson_id: 'lesson-1', + unit_progress_id: 'student-unit-1', + })); + coursesService.getLessonById.and.returnValue(of({ + id: 'lesson-1', + title: 'Mapping Africa', + activities: [], + })); + coursesService.getStudentCourses.and.returnValue(of([{ + course_id: 'course-1', + course: { + title: 'Introduction to Africa', + units: [{ + title: 'Place and People', + lessons: [ + { id: 'lesson-1', title: 'Mapping Africa', order: 1 }, + { id: 'lesson-2', title: 'Communities', order: 2 }, + ], + }], + }, + }] as any)); + + fixture = TestBed.createComponent(LessonViewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.textContent).toContain('Introduction to Africa'); + expect(compiled.textContent).toContain('Place and People'); + expect(compiled.textContent).toContain('Lesson 1 of 2'); + }); + it('keeps lesson error and governed states labeled for assistive technology', () => { const compiled = fixture.nativeElement as HTMLElement; const blockedState = compiled.querySelector('[data-echo-state="blocked"]') as HTMLElement; @@ -151,6 +187,32 @@ describe('LessonViewComponent', () => { expect(component.lesson?.title).toBe('Recovered lesson'); }); + it('shows a save failure without marking the lesson complete', () => { + const coursesService = TestBed.inject(CoursesService) as unknown as MockCoursesService; + coursesService.getCurrentSegment.and.returnValue(of({ + delivery_state: 'governed_available', + lesson_id: 'lesson-1', + unit_progress_id: 'student-unit-1', + })); + coursesService.getLessonById.and.returnValue(of({ + id: 'lesson-1', + title: 'Introduction to Africa', + activities: [], + })); + coursesService.markSegmentCompleted.and.returnValue(throwError(() => new Error('save failed'))); + + fixture = TestBed.createComponent(LessonViewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + component.onLessonCompleted(); + fixture.detectChanges(); + + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.textContent).toContain('We could not save this lesson completion'); + expect(component.courseCompleted).toBeFalse(); + }); + it('prefers the Introduction to Africa course for demo lessons', async () => { TestBed.resetTestingModule(); await TestBed.configureTestingModule({ diff --git a/frontend/src/app/pages/lesson-view.component.ts b/frontend/src/app/pages/lesson-view.component.ts index 6b63ce6..86f2d06 100644 --- a/frontend/src/app/pages/lesson-view.component.ts +++ b/frontend/src/app/pages/lesson-view.component.ts @@ -25,8 +25,14 @@ export class LessonViewComponent implements OnInit { demoMode = false; loading = true; loadErrorMessage = ''; + completionErrorMessage = ''; + completingSegment = false; governedDeliveryState: SegmentResponse['delivery_state'] | null = null; governedDeliveryDetail = ''; + courseContextTitle = ''; + courseContextId = ''; + unitContextTitle = ''; + lessonPositionLabel = ''; get governedStateTitle(): string { switch (this.governedDeliveryState) { @@ -145,6 +151,7 @@ export class LessonViewComponent implements OnInit { this.coursesService.getLessonById(lessonId).subscribe({ next: (lesson) => { this.lesson = lesson; + this.resolveLessonContext(lesson.id); this.loading = false; }, error: (err) => { @@ -169,13 +176,16 @@ export class LessonViewComponent implements OnInit { this.courseCompleted = true; return; } - if (!this.segment || !this.segment.lesson_id || !this.unitProgressId) { + if (this.completingSegment || !this.segment || !this.segment.lesson_id || !this.unitProgressId) { console.warn('Incomplete data to mark lesson complete'); return; } + this.completingSegment = true; + this.completionErrorMessage = ''; this.coursesService.markSegmentCompleted(this.unitProgressId, this.segment.lesson_id).subscribe({ next: (res: CompleteSegmentResponse) => { + this.completingSegment = false; const nextSeg = res.next_segment; if (nextSeg.delivery_state === 'governed_available' && nextSeg.lesson_id) { if (nextSeg.unit_progress_id) { @@ -198,6 +208,8 @@ export class LessonViewComponent implements OnInit { } }, error: (err) => { + this.completingSegment = false; + this.completionErrorMessage = 'We could not save this lesson completion. Your lesson is still open, so try again before leaving.'; console.error('Failed to mark segment complete:', err); } }); @@ -239,11 +251,59 @@ export class LessonViewComponent implements OnInit { returnToDashboard(): void { this.courseCompleted = false; - this.router.navigate(['/home']); + this.router.navigate(['/learn']); + } + + returnToCourse(): void { + this.courseCompleted = false; + if (this.courseContextId) { + this.router.navigate(['/learn/courses', this.courseContextId]); + return; + } + this.router.navigate(['/learn']); } retryLoad(): void { this.loadSegmentAndLesson(); } + private resolveLessonContext(lessonId: string): void { + this.courseContextTitle = ''; + this.courseContextId = ''; + this.unitContextTitle = ''; + this.lessonPositionLabel = ''; + + this.coursesService.getStudentCourses().subscribe({ + next: (studentCourses: StudentCourseWithDetails[]) => { + for (const studentCourse of studentCourses) { + const units = studentCourse.course.units || []; + for (const unit of units) { + const lessons = [...(unit.lessons || [])].sort((left, right) => { + const leftOrder = left.order ?? Number.MAX_SAFE_INTEGER; + const rightOrder = right.order ?? Number.MAX_SAFE_INTEGER; + if (leftOrder !== rightOrder) { + return leftOrder - rightOrder; + } + return left.title.localeCompare(right.title); + }); + const lessonIndex = lessons.findIndex(lesson => lesson.id === lessonId); + if (lessonIndex >= 0) { + this.courseContextTitle = studentCourse.course.title; + this.courseContextId = studentCourse.course_id; + this.unitContextTitle = unit.title; + this.lessonPositionLabel = `Lesson ${lessonIndex + 1} of ${lessons.length}`; + return; + } + } + } + }, + error: () => { + this.courseContextTitle = ''; + this.courseContextId = ''; + this.unitContextTitle = ''; + this.lessonPositionLabel = ''; + }, + }); + } + } diff --git a/frontend/src/app/pages/user-dashboard/student-view/student-view.component.spec.ts b/frontend/src/app/pages/user-dashboard/student-view/student-view.component.spec.ts index 1326d8b..8614fa1 100644 --- a/frontend/src/app/pages/user-dashboard/student-view/student-view.component.spec.ts +++ b/frontend/src/app/pages/user-dashboard/student-view/student-view.component.spec.ts @@ -398,7 +398,7 @@ describe('StudentViewComponent', () => { action.click(); - expect(router.navigate).toHaveBeenCalledWith(['/home/courses']); + expect(router.navigate).toHaveBeenCalledWith(['/learn/products']); }); it('keeps the continue lesson action bound to the selected course during remediation guidance', () => { diff --git a/frontend/src/app/pages/user-dashboard/student-view/student-view.component.ts b/frontend/src/app/pages/user-dashboard/student-view/student-view.component.ts index 68aebf6..d4b9eb3 100644 --- a/frontend/src/app/pages/user-dashboard/student-view/student-view.component.ts +++ b/frontend/src/app/pages/user-dashboard/student-view/student-view.component.ts @@ -122,7 +122,7 @@ export class StudentViewComponent implements OnInit { /** Navigate to the full available courses page */ goToAvailableCourses(): void { - this.router.navigate(['/home/courses']); + this.router.navigate(['/learn/products']); } @@ -395,7 +395,7 @@ enrollInCourse(courseId: string): void { if (segment.delivery_state === 'governed_available' && segment.unit_progress_id) { this.governedDeliveryState = null; this.governedDeliveryDetail = ''; - this.router.navigate(['/home/lesson', segment.unit_progress_id]); + this.router.navigate(['/learn/lesson', segment.unit_progress_id]); return; } @@ -406,11 +406,11 @@ enrollInCourse(courseId: string): void { } goToPrograms(): void { - this.router.navigate(['/home/programs']); + this.router.navigate(['/learn/paths']); } goToCertifications(): void { - this.router.navigate(['/home/certifications']); + this.router.navigate(['/learn/certificates']); } retryCourseLoad(): void { diff --git a/frontend/src/app/shared/lesson-viewer.component.html b/frontend/src/app/shared/lesson-viewer.component.html index 943bfcb..663531e 100644 --- a/frontend/src/app/shared/lesson-viewer.component.html +++ b/frontend/src/app/shared/lesson-viewer.component.html @@ -73,11 +73,15 @@

- Step {{ currentStepIndex + 1 }} of {{ stepCount }} + {{ currentStepStateLabel }} ยท Step {{ currentStepIndex + 1 }} of {{ stepCount }}

+

{{ currentStepInstruction }}

+ -
+
diff --git a/frontend/src/app/shared/lesson-viewer.component.scss b/frontend/src/app/shared/lesson-viewer.component.scss index 6ab1451..3371346 100644 --- a/frontend/src/app/shared/lesson-viewer.component.scss +++ b/frontend/src/app/shared/lesson-viewer.component.scss @@ -7,6 +7,22 @@ min-width: 0; } +.lesson-viewer__step-instruction { + color: var(--ee-text-soft, #475569); + font-size: 0.9rem; + line-height: 1.55; +} + +.lesson-viewer__activity-error { + background: var(--ee-color-status-danger-background, #fff1f3); + border: 1px solid var(--ee-color-status-danger-border, #ffd9df); + border-radius: var(--ee-radius-card-default, 0.5rem); + color: var(--ee-color-status-danger-text, #9f1239); + font-size: 0.9rem; + line-height: 1.5; + padding: 0.75rem; +} + .lesson-viewer__step-list { display: flex; gap: 0.5rem; diff --git a/frontend/src/app/shared/lesson-viewer.component.spec.ts b/frontend/src/app/shared/lesson-viewer.component.spec.ts index 48a0eef..948a4ce 100644 --- a/frontend/src/app/shared/lesson-viewer.component.spec.ts +++ b/frontend/src/app/shared/lesson-viewer.component.spec.ts @@ -102,6 +102,40 @@ describe('LessonViewerComponent', () => { expect(radiogroup.getAttribute('aria-label')).toBe('Quiz response options'); }); + it('keeps quiz activity input in place and announces validation before advancing', () => { + component.lesson = { + ...component.lesson, + hook: undefined, + content: undefined, + guided_practice: undefined, + discussion_questions: [], + activities: [{ + title: 'Check Your Understanding', + type: 'quiz', + content: JSON.stringify({ + question: 'Which claim is best supported?', + options: ['Claim A', 'Claim B'], + }), + } as any], + }; + + fixture.detectChanges(); + component.goToStep(component.lessonSteps.findIndex(step => step.kind === 'activity')); + fixture.detectChanges(); + + component.goToNextStep(); + fixture.detectChanges(); + + expect(component.currentStep?.kind).toBe('activity'); + expect(fixture.nativeElement.textContent).toContain('Choose an answer before continuing'); + + component.selectedOption = 'Claim A'; + fixture.detectChanges(); + component.goToNextStep(); + + expect(component.activityErrorMessage).toBe(''); + }); + it('preserves next-activity flow for richer seeded activity patterns', () => { component.lesson = { ...component.lesson, diff --git a/frontend/src/app/shared/lesson-viewer.component.ts b/frontend/src/app/shared/lesson-viewer.component.ts index 4b99b57..2337475 100644 --- a/frontend/src/app/shared/lesson-viewer.component.ts +++ b/frontend/src/app/shared/lesson-viewer.component.ts @@ -32,6 +32,7 @@ export class LessonViewerComponent implements OnChanges { currentStepIndex = 0; reflectionResponse = ''; selectedOption = ''; + activityErrorMessage = ''; ngOnChanges(changes: SimpleChanges): void { if (changes['lesson']) { @@ -39,6 +40,7 @@ export class LessonViewerComponent implements OnChanges { this.currentStepIndex = 0; this.reflectionResponse = ''; this.selectedOption = ''; + this.activityErrorMessage = ''; } } @@ -138,6 +140,36 @@ export class LessonViewerComponent implements OnChanges { return this.currentStep?.label || 'Lesson'; } + get currentStepStateLabel(): string { + if (!this.currentStep) { + return 'Not started'; + } + if (this.isLastActivity) { + return 'Ready to complete'; + } + if (this.currentStep.kind === 'activity' && this.quizPayload && !this.selectedOption) { + return 'In progress'; + } + return 'Ready to continue'; + } + + get currentStepInstruction(): string { + const step = this.currentStep; + if (!step) { + return 'Start the lesson when you are ready.'; + } + if (step.kind === 'activity' && this.quizPayload) { + return 'Choose one answer before moving to the next step.'; + } + if (step.kind === 'activity' && this.currentActivity?.type === 'reflection') { + return 'Write your reflection, then continue when you are ready.'; + } + if (step.kind === 'discussion') { + return 'Use the prompts to think, talk, draw, or write.'; + } + return 'Review this step, then continue when you are ready.'; + } + get currentStepTitle(): string { const step = this.currentStep; if (!step) { @@ -185,6 +217,9 @@ export class LessonViewerComponent implements OnChanges { } goToNextStep(): void { + if (!this.canLeaveCurrentStep()) { + return; + } if (this.currentStepIndex < this.stepCount - 1) { this.currentStepIndex++; this.syncActivityIndex(); @@ -209,6 +244,16 @@ export class LessonViewerComponent implements OnChanges { private resetStepInputs(): void { this.reflectionResponse = ''; this.selectedOption = ''; + this.activityErrorMessage = ''; + } + + private canLeaveCurrentStep(): boolean { + if (this.currentStep?.kind === 'activity' && this.quizPayload && !this.selectedOption) { + this.activityErrorMessage = 'Choose an answer before continuing. Your selection is not submitted until the lesson is completed.'; + return false; + } + this.activityErrorMessage = ''; + return true; } isYouTubeLink(url: string): boolean { diff --git a/frontend/tests/demo/student-flagship-smoke.spec.ts b/frontend/tests/demo/student-flagship-smoke.spec.ts index e84fc9c..7d72535 100644 --- a/frontend/tests/demo/student-flagship-smoke.spec.ts +++ b/frontend/tests/demo/student-flagship-smoke.spec.ts @@ -11,13 +11,13 @@ test.describe('demo student flagship smoke', () => { await loginAsDemoStudent(page); await expectFlagshipCourseOnStudentDashboard(page); - await expect(page.getByText('Products organize your access')).toBeVisible(); - await page.getByRole('button', { name: /continue/i }).first().click(); + await expect(page.getByText('Next learning')).toBeVisible(); + await page.getByRole('button', { name: /start lesson|resume lesson/i }).first().click(); await expect(page).toHaveURL(/\/learn\/lesson\//); await expect(page.getByLabel('Lesson experience')).toBeVisible(); await expect( - page.getByRole('button', { name: 'Exit lesson and return to dashboard' }), + page.getByRole('button', { name: 'Exit lesson and return to Learn' }), ).toBeVisible(); }); }); diff --git a/frontend/tests/demo/support/demo-smoke.ts b/frontend/tests/demo/support/demo-smoke.ts index 148c091..a095d06 100644 --- a/frontend/tests/demo/support/demo-smoke.ts +++ b/frontend/tests/demo/support/demo-smoke.ts @@ -16,7 +16,7 @@ export async function loginAsDemoStudent(page: Page): Promise { await page.getByRole('button', { name: 'Sign in' }).click(); await expect(page).toHaveURL(/\/learn$/); - await expect(page.getByRole('heading', { name: 'Your products and learning paths' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Continue your learning' })).toBeVisible(); } export async function expectFlagshipCourseOnStudentDashboard(page: Page): Promise { diff --git a/openspec/changes/overhaul-role-based-ui-ux-experience/tasks.md b/openspec/changes/overhaul-role-based-ui-ux-experience/tasks.md index 61e46f0..66840e1 100644 --- a/openspec/changes/overhaul-role-based-ui-ux-experience/tasks.md +++ b/openspec/changes/overhaul-role-based-ui-ux-experience/tasks.md @@ -26,10 +26,16 @@ ## 5. Student Experience -- [ ] 5.1 Make `/learn` the student-first dashboard using existing learner/student data; affected screens: `/learn`, current student dashboard; APIs: `/api/student-courses`, `/api/analytics/student-progress`, badge/certification/program APIs; tests: student view specs; a11y: next action first; responsive: mobile bottom nav; frontend-only: yes; rollback: current dashboard. -- [ ] 5.2 Redesign learner course library and course overview; affected screens: `/learn/products`, `/home/courses`; APIs: `/api/courses`, `/api/learner-portal/products`, `/api/enroll`; tests: service/page specs; a11y: card labels/status; responsive: filter drawer; frontend-only: yes; rollback: current catalog. -- [ ] 5.3 Redesign lesson player and activity/assessment states without changing governed progress APIs; affected screens: `/learn/lesson/:id`, `/home/lesson/:id`, `/home/assessments/:id`; APIs: progress, lessons, assessments; tests: lesson/assessment specs and Playwright student smoke; a11y: live save/advance announcements; responsive: sticky mobile action bar; frontend-only: yes; rollback: old lesson template. -- [ ] 5.4 Redesign progress, achievements, certifications, and learner resources; affected screens: progress sections, `/learn/certificates`, `/learn/resources`; APIs: analytics, badges, certifications, resources; tests: component/page specs; a11y: no color-only progress; responsive: single-column mobile; frontend-only: yes; rollback: previous sections. +- [x] 5.1 Make `/learn` the student-first dashboard using existing learner/student data; affected screens: `/learn`, current student dashboard; APIs: `/api/student-courses`, `/api/analytics/student-progress`, badge/certification/program APIs; tests: student view specs; a11y: next action first; responsive: mobile bottom nav; frontend-only: yes; rollback: current dashboard. +- [x] 5.2 Redesign learner course library and course overview; affected screens: `/learn/products`, `/home/courses`; APIs: `/api/courses`, `/api/learner-portal/products`, `/api/enroll`; tests: service/page specs; a11y: card labels/status; responsive: filter drawer; frontend-only: yes; rollback: current catalog. + - [x] 5.2a Reframe `/learn/products` as student Courses/available learning while preserving the existing learner-product API. + - [x] 5.2b Add canonical `/learn/courses/:courseId` overview with reusable unit/lesson hierarchy and governed start/resume actions. +- [x] 5.3 Redesign lesson player and activity/assessment states without changing governed progress APIs; affected screens: `/learn/lesson/:id`, `/home/lesson/:id`, `/home/assessments/:id`; APIs: progress, lessons, assessments; tests: lesson/assessment specs and Playwright student smoke; a11y: live save/advance announcements; responsive: sticky mobile action bar; frontend-only: yes; rollback: old lesson template. + - [x] 5.3a Add canonical `/learn` return behavior plus completion saving and failure states to the governed lesson runtime. + - [x] 5.3b Add course/unit context to lesson runtime, local activity validation states, and assessment final confirmation/result states. +- [x] 5.4 Redesign progress, achievements, certifications, and learner resources; affected screens: progress sections, `/learn/certificates`, `/learn/resources`; APIs: analytics, badges, certifications, resources; tests: component/page specs; a11y: no color-only progress; responsive: single-column mobile; frontend-only: yes; rollback: previous sections. + - [x] 5.4a Add `/learn` progress summary plus badge/certification preview and honest resources empty state. + - [x] 5.4b Refine `/learn/certificates` as a student achievements/certificates view with shared loading, empty, and error states. ## 6. Teacher Experience