From 52e84d68cbb159d8f5a3ccf811ed30a4758bacf6 Mon Sep 17 00:00:00 2001 From: Anthony Wright Date: Sun, 12 Jul 2026 10:10:01 -0500 Subject: [PATCH] Implement Teacher Teach production experience --- .../ux-overhaul/production-route-migration.md | 20 +- docs/ux-overhaul/shared-component-status.md | 13 + .../teacher-accessibility-review.md | 21 + .../teacher-assignment-data-contract.md | 35 + docs/ux-overhaul/teacher-privacy-review.md | 18 + docs/ux-overhaul/teacher-production-audit.md | 37 + .../teacher-production-implementation.md | 54 ++ docs/ux-overhaul/teacher-responsive-review.md | 21 + docs/ux-overhaul/teacher-review-behavior.md | 22 + .../teacher-role-capability-map.md | 18 + frontend/src/app/app.routes.spec.ts | 30 + frontend/src/app/app.routes.ts | 54 ++ frontend/src/app/models/section.ts | 18 + .../section-detail.component.html | 243 +++++- .../section-detail.component.scss | 274 ++++++ .../section-detail.component.ts | 172 +++- .../pages/sections/sections.component.html | 124 ++- .../pages/sections/sections.component.scss | 202 +++++ .../app/pages/sections/sections.component.ts | 100 ++- .../teacher-course-preview.component.scss | 104 +++ .../teacher-course-preview.component.ts | 190 +++++ .../teacher-curriculum.component.scss | 141 +++ .../teacher-curriculum.component.ts | 137 +++ .../teacher-learner-detail.component.scss | 93 ++ .../teacher-learner-detail.component.ts | 153 ++++ .../echoed-role-selector.component.html | 4 +- .../teacher-view/teacher-view.component.html | 805 +++--------------- .../teacher-view/teacher-view.component.scss | 247 ++++-- .../teacher-view.component.spec.ts | 641 +++----------- .../teacher-view/teacher-view.component.ts | 3 +- frontend/src/app/services/sections.service.ts | 10 +- .../app/services/shell-navigation.service.ts | 11 +- .../tasks.md | 8 +- 33 files changed, 2630 insertions(+), 1393 deletions(-) create mode 100644 docs/ux-overhaul/teacher-accessibility-review.md create mode 100644 docs/ux-overhaul/teacher-assignment-data-contract.md create mode 100644 docs/ux-overhaul/teacher-privacy-review.md create mode 100644 docs/ux-overhaul/teacher-production-audit.md create mode 100644 docs/ux-overhaul/teacher-production-implementation.md create mode 100644 docs/ux-overhaul/teacher-responsive-review.md create mode 100644 docs/ux-overhaul/teacher-review-behavior.md create mode 100644 docs/ux-overhaul/teacher-role-capability-map.md create mode 100644 frontend/src/app/pages/teacher-curriculum/teacher-course-preview.component.scss create mode 100644 frontend/src/app/pages/teacher-curriculum/teacher-course-preview.component.ts create mode 100644 frontend/src/app/pages/teacher-curriculum/teacher-curriculum.component.scss create mode 100644 frontend/src/app/pages/teacher-curriculum/teacher-curriculum.component.ts create mode 100644 frontend/src/app/pages/teacher-learner-detail/teacher-learner-detail.component.scss create mode 100644 frontend/src/app/pages/teacher-learner-detail/teacher-learner-detail.component.ts diff --git a/docs/ux-overhaul/production-route-migration.md b/docs/ux-overhaul/production-route-migration.md index 3d1e75b..ecd3a79 100644 --- a/docs/ux-overhaul/production-route-migration.md +++ b/docs/ux-overhaul/production-route-migration.md @@ -1,10 +1,17 @@ # Production Route Migration -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. +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. Phase 3 adds canonical teacher `/teach` routes while preserving `/home` and workspace cohort deep links. | 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 | +| `/home` | `/teach` for teacher/instructor; `/home` remains admin transitional | teacher, instructor, admin, super_admin-compatible | No redirect | `HomeSessionGuard`; child guards unchanged | Phase 3 teacher alias added | Remove teacher reliance only after `/teach` smoke tests and usage validation | High | +| `/teach` | `/teach` | teacher, instructor | No redirect; role landing now points here | `HomeSessionGuard`; child guards unchanged | Phase 3 | None planned | Low | +| `/teach/classes` | `/teach/classes` | teacher, instructor, org_admin | No redirect | `HomeSessionGuard` plus `RoleGuard` section roles | Phase 3 | None planned | Low | +| `/teach/classes/:id` | `/teach/classes/:id` | teacher, instructor, org_admin | No redirect | `HomeSessionGuard` plus `RoleGuard` section roles; frontend verifies route section is in authorized section list | Phase 3 | None planned | Low | +| `/teach/curriculum` | `/teach/curriculum` | teacher, instructor | No redirect | `HomeSessionGuard` plus `RoleGuard` teacher/instructor | Phase 3 | None planned | Low | +| `/teach/courses/:courseId/preview` | `/teach/courses/:courseId/preview` | teacher, instructor | No redirect | `HomeSessionGuard` plus `RoleGuard` teacher/instructor; preview uses course load only | Phase 3 | None planned | Low | +| `/teach/assignments` | `/teach/classes` class-first assignment entry | teacher, instructor, org_admin | No redirect; currently renders class list to choose assignment context | `HomeSessionGuard` plus `RoleGuard` section roles | Phase 3 | Replace only if global assignment API is added | Low | +| `/teach/learners/:learnerId` | `/teach/learners/:learnerId?sectionId=` | teacher, instructor, org_admin | No redirect | `HomeSessionGuard` plus `RoleGuard`; frontend requires section context and roster membership | Phase 3 | None planned | Low | | `/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 | @@ -18,7 +25,8 @@ Phase 1 preserves route compatibility and establishes canonical role destination | `/home/admin/users` | `/home/admin/users` | admin | No redirect | Existing `RoleGuard` admin-only | Phase 1 | Dedicated Admin IA route exists and old path is aliased | High | | `/home/admin/courses` | `/home/admin/courses` | admin | No redirect | Existing `RoleGuard` admin-only | Phase 1 | Dedicated Admin IA route exists and old path is aliased | High | | `/home/admin/badges` | `/home/admin/badges` | admin | No redirect | Existing `RoleGuard` admin-only | Phase 1 | Dedicated Admin IA route exists and old path is aliased | High | -| `/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/sections` | `/teach/classes` | teacher, instructor, org_admin | No redirect | Existing `RoleGuard` section roles | Phase 3 alias added | Remove only after teacher bookmarks and smoke tests move to `/teach/classes` | High | +| `/home/sections/:id` | `/teach/classes/:id` | teacher, instructor, org_admin | No redirect | Existing `RoleGuard` section roles | Phase 3 alias added | Remove only after class detail deep links have compatibility redirects | 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 | @@ -28,8 +36,8 @@ Phase 1 preserves route compatibility and establishes canonical role destination ## Canonical Login Destinations - `student`: `/learn` -- `teacher`: `/home` -- `instructor`: `/home` +- `teacher`: `/teach` +- `instructor`: `/teach` - `content_admin`: `/workspace` - `org_admin`: `/workspace/learners` - `admin`: `/home` @@ -43,3 +51,5 @@ Phase 1 preserves route compatibility and establishes canonical role destination - 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. +- Phase 3 adds canonical Teach routes without deleting `/home/sections`, `/home/sections/:id`, or `/workspace/learners/cohorts` compatibility routes. +- Teacher course preview intentionally avoids `/api/start-course`; it uses `/api/courses/{id}` only. diff --git a/docs/ux-overhaul/shared-component-status.md b/docs/ux-overhaul/shared-component-status.md index ac85d09..a7dea39 100644 --- a/docs/ux-overhaul/shared-component-status.md +++ b/docs/ux-overhaul/shared-component-status.md @@ -23,6 +23,17 @@ | State panel | `frontend/src/app/components/echo-state-panel/` | Production active | Adds `permission` and `success` variants and real icon rendering. | | Access denied | `frontend/src/app/pages/access-denied/` | Migrated | Uses shared permission state. | | Icon registry | `frontend/src/app/shared/icon/icon.module.ts` | Extended | Adds `Lock` and `CheckCircle` paths. | +| Student curriculum | `frontend/src/app/components/student-curriculum/student-curriculum.component.ts` | Reused in teacher preview | Teacher course preview renders the same unit/lesson hierarchy in preview-only mode without starting learner progress. | +| Confirmation dialog | `frontend/src/app/components/echo-confirmation-dialog/` | Connected to teacher assignment creation | Class assignments require review confirmation before calling the existing section assignment API. | +| State panel/loading state | `frontend/src/app/components/echo-state-panel/`, `frontend/src/app/components/echo-loading-state/` | Connected to Teach routes | Teacher overview, classes, class detail, curriculum, course preview, and learner detail use shared loading/error/empty/permission states. | + +## Added In Teacher Phase + +| Component/screen | Path | Status | Notes | +| --- | --- | --- | --- | +| Teacher curriculum page | `frontend/src/app/pages/teacher-curriculum/teacher-curriculum.component.ts` | Production active | Course search and preview/assign entry over `/api/courses`. | +| Teacher course preview | `frontend/src/app/pages/teacher-curriculum/teacher-course-preview.component.ts` | Production active | Preview-only course hierarchy over `/api/courses/{id}`. | +| Teacher learner detail | `frontend/src/app/pages/teacher-learner-detail/teacher-learner-detail.component.ts` | Production active | Requires class `sectionId` context and roster verification. | ## Still Legacy @@ -31,6 +42,8 @@ - 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. +- Global teacher assignment list is still a class-first alias because no global assignment API was verified. +- Teacher feedback panels remain deferred because no feedback persistence endpoint was verified. ## Required Future Adoption diff --git a/docs/ux-overhaul/teacher-accessibility-review.md b/docs/ux-overhaul/teacher-accessibility-review.md new file mode 100644 index 0000000..42022a8 --- /dev/null +++ b/docs/ux-overhaul/teacher-accessibility-review.md @@ -0,0 +1,21 @@ +# Teacher Accessibility Review + +Date: 2026-07-12 + +## Addressed + +- One visible `h1` per new teacher screen. +- Logical heading hierarchy for overview, class, curriculum, preview, and learner detail. +- Keyboard-accessible links, buttons, forms, and tab controls. +- Visible focus styles on custom teacher controls. +- Loading states use shared status semantics. +- Error and permission states use shared state panels. +- Assignment confirmation uses shared dialog with focus management. +- Required assignment/class fields have visible labels and inline errors. +- Progress values include text, not color-only bars. +- No essential row action is hover-only. +- Course preview clearly announces that no progress is recorded. + +## Remaining Manual QA + +Manual screen-reader and keyboard traversal should be performed in a running browser at 1440, 1280, 768, and 390 px once local frontend/backend infrastructure is available. diff --git a/docs/ux-overhaul/teacher-assignment-data-contract.md b/docs/ux-overhaul/teacher-assignment-data-contract.md new file mode 100644 index 0000000..87a1f5b --- /dev/null +++ b/docs/ux-overhaul/teacher-assignment-data-contract.md @@ -0,0 +1,35 @@ +# Teacher Assignment Data Contract + +Date: 2026-07-12 + +## Existing Assignment Model + +| Field | Source | Supported in UI | Notes | +| --- | --- | --- | --- | +| Assignment ID | `AssignmentResponse.id` | Display/list only | UUID, not exposed as primary UI label. | +| Section/class target | URL section ID and `section_id` | Yes | Class-first workflow. | +| Target type | `target_type` | Yes | UI supports `course`, `unit`, and `lesson`; backend enum must accept the chosen value. | +| Target ID | `target_id` | Yes | Course picker is available for `course`; unit/lesson still require IDs because no picker endpoint was verified. | +| Due date | `due_at` | Yes | Uses `datetime-local`; backend receives ISO-like browser value through Angular binding. | +| Instructions | `instructions` | Yes | Preserved after recoverable failures. | +| Created by | `created_by` | Not shown | Internal teacher identifier not exposed. | +| Created at | `created_at` | Available | Used for ordering only when needed. | +| Assigned learners | Not in current assignment create response | No | Class assignment endpoint does not support individual learner selection. | +| Status/publishing state | Not exposed | No | UI does not invent draft/published states. | +| Completion/review state | Submission model exists separately | Limited | No teacher-facing per-learner completion endpoint verified. | +| Retry/reassignment | Not exposed | No | Not implemented. | +| Edit/delete/archive | No endpoint verified | No | UI does not show destructive assignment actions. | +| Notifications | No endpoint verified | No | UI does not claim notifications are sent. | + +## Creation Behavior + +The class detail assignment tab uses: + +- Required target validation. +- Optional due date and instructions. +- Confirmation dialog before assignment creation. +- Duplicate-submit prevention while saving. +- Persistent inline error if creation fails. +- Success toast only after API confirmation. + +Known limitation: the API creates section-wide assignments only; the UI states that individual learner selection is not supported by the current endpoint. diff --git a/docs/ux-overhaul/teacher-privacy-review.md b/docs/ux-overhaul/teacher-privacy-review.md new file mode 100644 index 0000000..d9c1b09 --- /dev/null +++ b/docs/ux-overhaul/teacher-privacy-review.md @@ -0,0 +1,18 @@ +# Teacher Privacy Review + +Date: 2026-07-12 + +## Decisions + +- Canonical learner detail requires `sectionId` and verifies the learner appears in that section roster before showing profile context. +- Roster rows show learner name only when the authorized student list includes that profile. +- Emails, internal organization IDs, raw learner IDs, demographic data, and global learner search are not shown in teacher UI. +- Deep-link learner failures use neutral permission language and do not reveal whether a learner exists outside the teacher's authorized class context. +- Course preview does not call `startCourse()` and does not create teacher enrollment, learner enrollment, lesson sessions, or progress records. +- Unsupported discussion moderation is not exposed because thread/post APIs are not class-scoped or permission-scoped. + +## Remaining Backend Privacy Gaps + +- Section roster/enrollment/assignment APIs should verify the target section belongs to the active organization and the educator's authorized scope. +- Assignment listing should require an educator or authorized learner dependency. +- Thread/post APIs should require authentication and class/community scope before Teach moderation is exposed. diff --git a/docs/ux-overhaul/teacher-production-audit.md b/docs/ux-overhaul/teacher-production-audit.md new file mode 100644 index 0000000..7208de0 --- /dev/null +++ b/docs/ux-overhaul/teacher-production-audit.md @@ -0,0 +1,37 @@ +# Teacher Production Audit + +Date: 2026-07-12 + +## Summary + +The audited teacher/instructor production surface uses the existing FastAPI auth, organization-role, section, assignment, course, learner-progress, assessment, and discussion APIs. No backend files or database schema changes were required for this phase. + +## Routes Audited + +| Route | User goal | Role access | API dependencies | Current UX problems found | Privacy/accessibility/responsive risks | Migration recommendation | +| --- | --- | --- | --- | --- | --- | --- | +| `/home` | Teacher landing through role selector | `teacher`; now `instructor` compatible in role selector | `/api/courses`, `/api/users/students`, `/api/analytics/teacher-summary`, governance summary | Old dashboard mixed demo/governance/course-authoring language with teacher work | Progress rows are not class-scoped; dense legacy tables; demo copy | Canonical `/teach`; keep `/home` legacy | +| `/teach` | Teacher Teach overview | `teacher`, `instructor` through shell/bootstrap | Same as `/home` teacher view | New canonical route added | Must not imply unsupported review/feedback counts | Canonical Phase 3 route | +| `/home/sections` | List/create sections | `teacher`, `instructor`, `org_admin` | `/api/sections`, `/api/sections/{id}/analytics/summary` | Thin CRUD, exposed internal course version ID without explanation | No loading/error states; no responsive data-list | Keep as legacy alias; canonical `/teach/classes` | +| `/teach/classes` | List/create classes | `teacher`, `instructor`, `org_admin` | Same as `/home/sections` | New production class list | Course version ID is still technical due to backend contract | Canonical Phase 3 route | +| `/home/sections/:id` | Manage section assignments | `teacher`, `instructor`, `org_admin` | `/api/sections`, `/api/sections/{id}/roster`, `/api/sections/{id}/assignments`, `/api/sections/{id}/analytics/summary`, `/api/users/students`, `/api/courses` | Old page only created assignment by raw target ID | Roster endpoint returns no learner profile fields; section ownership check is incomplete in backend | Keep as legacy alias; canonical `/teach/classes/:id` | +| `/teach/classes/:id` | Class overview, roster, assignments, progress, discussion note | `teacher`, `instructor`, `org_admin` | Same as section detail | New production class detail | Frontend verifies selected section is in authorized section list; backend remains authoritative | Canonical Phase 3 route | +| `/teach/curriculum` | Browse curriculum for assignment | `teacher`, `instructor` | `/api/courses` | New route | Client-side search only; no standards/duration invented | Canonical Phase 3 route | +| `/teach/courses/:courseId/preview` | Preview learner course hierarchy | `teacher`, `instructor` | `/api/courses/{id}` | New route | Does not call `/api/start-course`; no progress is recorded | Canonical Phase 3 route | +| `/teach/assignments` | Assignment entry point | `teacher`, `instructor`, `org_admin` | `/api/sections`, section assignment APIs after class selection | New alias to class list | Global assignment list endpoint does not exist | Keep as class-first assignment entry | +| `/teach/learners/:learnerId?sectionId=` | Learner detail with class context | `teacher`, `instructor`, `org_admin` | `/api/sections`, `/api/sections/{id}/roster`, `/api/users/students` | New route | Requires section context and roster membership; no global learner profile exposure | Canonical Phase 3 scoped learner detail | +| `/workspace/learners/cohorts` | Legacy class alias under workspace | creator roles; cohort child uses section roles | `/api/sections` | Commercial/workspace mental model for teachers | Route name does not match teacher task | Preserve legacy alias, do not promote | +| `/home/courses` | Legacy mixed course catalog | unguarded child under authenticated home | student/teacher transitional | Student and teacher goals mixed | Possible wrong mental model | Teacher navigation now uses `/teach/curriculum` | +| `/home/assessments/:id` | Assessment detail/submission | authenticated child; submit requires student | `/api/assessments/{id}`, `/api/assessments/{id}/attempts` | Student-oriented submit flow | Teachers can view assessment questions but no review/feedback workflow | Use as assessment preview only; no teacher grading | +| `/workspace/review-center` | Content review center | creator roles | V2 review center APIs | Content/studio review, not student work review | Could confuse teacher feedback | Not exposed in Teach nav | +| `/threads`, `/posts` APIs | Discussion primitives | No route guards in audited backend modules | `/api/threads`, `/api/posts` | No class scope or moderation permission model | High privacy risk if exposed as teacher moderation | Document gap; do not expose class feed | + +## Backend Authorization Risks Found + +- `GET /api/sections/{id}/roster` requires a teacher/org/instructor org role, but does not verify the section belongs to that organization. +- `POST /api/sections/{id}/enrollments` has the same section-scope risk. +- `GET /api/sections/{id}/assignments` currently has no explicit role dependency. +- `GET /api/analytics/teacher-summary` supports `admin` and `teacher`, but not `instructor`. +- Thread/post endpoints are not authenticated or class-scoped in the audited files. + +Frontend work keeps navigation and views scoped, but these backend risks remain documented gaps. diff --git a/docs/ux-overhaul/teacher-production-implementation.md b/docs/ux-overhaul/teacher-production-implementation.md new file mode 100644 index 0000000..53258d0 --- /dev/null +++ b/docs/ux-overhaul/teacher-production-implementation.md @@ -0,0 +1,54 @@ +# Teacher Production Implementation + +Date: 2026-07-12 + +## Implemented + +- Canonical Teach route family: + - `/teach` + - `/teach/classes` + - `/teach/classes/:id` + - `/teach/curriculum` + - `/teach/courses/:courseId/preview` + - `/teach/assignments` + - `/teach/learners/:learnerId` +- Teacher/instructor navigation now points to Teach routes. +- Legacy `/home/sections`, `/home/sections/:id`, and `/workspace/learners/cohorts` routes remain available. +- Teacher home reframed around today, classes, learner progress, curriculum preview, and honest review/discussion limitations. +- Classes list adds loading, empty, error, search, responsive cards, and explicit course-version contract copy. +- Class detail adds overview, roster, assignments, progress, and discussion tabs over existing APIs. +- Assignment creation uses confirmation dialog and preserves values on failure. +- Curriculum browse and course preview use existing course APIs. +- Learner detail is scoped to class roster context. + +## Backend Changes + +None. + +## APIs Used + +- `/api/courses` +- `/api/courses/{id}` +- `/api/users/students` +- `/api/analytics/teacher-summary` +- `/api/analytics/educator-runtime-support` +- `/api/sections` +- `/api/sections/{id}/roster` +- `/api/sections/{id}/assignments` +- `/api/sections/{id}/analytics/summary` + +## Known Limitations + +- Instructor-compatible UI exists, but some analytics endpoints still require backend support for `instructor`. +- Per-learner assignment state, feedback history, manual review, and return-work workflows are not verified. +- Unit/lesson assignment target pickers need richer APIs before they can avoid raw IDs. +- Discussion moderation remains deferred until thread/post APIs are scoped and authorized. + +## Verification + +- App TypeScript compile: `npx tsc -p tsconfig.app.json --noEmit` passed. +- Production build: `npm run build` passed with existing Sass deprecation warnings in global typography. +- Frontend unit tests: `npx ng test --watch=false --browsers=ChromeHeadless` passed with `233 SUCCESS`. +- Playwright discovery: `npx playwright test --list` passed; 1 demo smoke test discovered. +- Strict OpenSpec validation: `openspec validate overhaul-role-based-ui-ux-experience --strict` passed. +- Browser execution remains blocked because local TCP checks failed for frontend `127.0.0.1:4200`, backend `127.0.0.1:8000`, and PostgreSQL `127.0.0.1:5432`. diff --git a/docs/ux-overhaul/teacher-responsive-review.md b/docs/ux-overhaul/teacher-responsive-review.md new file mode 100644 index 0000000..bf6df62 --- /dev/null +++ b/docs/ux-overhaul/teacher-responsive-review.md @@ -0,0 +1,21 @@ +# Teacher Responsive Review + +Date: 2026-07-12 + +## Implemented Responsive Patterns + +- Teach overview stacks priorities, data lists, and next actions at tablet/mobile widths. +- Classes list uses responsive cards rather than a desktop table. +- Class detail tabs are horizontally scrollable and class data rows collapse to single-column cards. +- Assignment form collapses to a single column on narrow screens. +- Curriculum cards move from two columns to one column. +- Course preview changes from two-column header to one column. +- Learner detail metrics collapse to one column. + +## Static Checks + +CSS uses constrained grids, wrapping actions, 44px minimum control height, and no hover-only action affordances. + +## Remaining Browser QA + +Seeded browser execution still depends on local frontend/backend/database availability. Visual verification at 1440, 1280, 768, and 390 px remains a final manual/browser task if infrastructure is unavailable. diff --git a/docs/ux-overhaul/teacher-review-behavior.md b/docs/ux-overhaul/teacher-review-behavior.md new file mode 100644 index 0000000..40bfdc2 --- /dev/null +++ b/docs/ux-overhaul/teacher-review-behavior.md @@ -0,0 +1,22 @@ +# Teacher Review Behavior + +Date: 2026-07-12 + +## Assessment and Work Review Findings + +| Behavior | Verified support | Evidence | +| --- | --- | --- | +| Teachers can load assessment details | Yes | `GET /api/assessments/{id}` includes questions for staff users. | +| Students can submit assessment attempts | Yes | `POST /api/assessments/{id}/attempts` requires `student`. | +| Auto-scoring | Yes | Assessment submit endpoint scores answers and records events. | +| Teachers can list work needing review | Not verified | No teacher review queue endpoint found for student assignment submissions. | +| Teachers can manually score | Not verified | No score mutation endpoint for teachers found. | +| Teachers can comment/save feedback | Not verified | No feedback persistence endpoint found. | +| Teachers can publish/return feedback | Not verified | No return-work endpoint found. | +| Retries can be granted | Not verified | Assessment max attempts exist, but no teacher retry grant endpoint found. | +| Correct answers visible to staff | Partial | Staff assessment serialization includes questions; answer visibility is model-dependent. | +| Rubric support | Not verified | No rubric endpoint surfaced. | + +## UI Decision + +Teach surfaces describe work review and feedback as unavailable unless the current API confirms a supported action. No toast-only confirmation, manual grading state, returned-work state, or retry state was invented. diff --git a/docs/ux-overhaul/teacher-role-capability-map.md b/docs/ux-overhaul/teacher-role-capability-map.md new file mode 100644 index 0000000..772ff7f --- /dev/null +++ b/docs/ux-overhaul/teacher-role-capability-map.md @@ -0,0 +1,18 @@ +# Teacher Role Capability Map + +Date: 2026-07-12 + +| Capability | Teacher | Instructor | Org admin in class routes | Notes | +| --- | --- | --- | --- | --- | +| Teach overview | Yes | Yes | No primary nav | Uses existing teacher dashboard component. Instructor compatibility is UI-level; some analytics endpoints still require backend expansion. | +| Classes list | Yes | Yes | Yes | Uses `/api/sections`. | +| Create class | Yes | Yes | Yes | Requires current course version ID contract. | +| Class roster | Yes | Yes | Yes | Uses `/api/sections/{id}/roster`; learner names are matched from `/api/users/students` where available. | +| Create class assignment | Yes | Yes | Yes | Uses `/api/sections/{id}/assignments`; no individual learner targeting in this endpoint. | +| Curriculum browse | Yes | Yes | No | Uses `/api/courses`. | +| Course preview | Yes | Yes | No | Uses `/api/courses/{id}` only; does not create learner progress. | +| Learner detail | Yes, class scoped | Yes, class scoped | Yes, class scoped | Requires `sectionId` and roster membership. | +| Progress monitoring | Partial | Partial | Partial | Section summary and teacher summary are supported; class-scoped learner detail is not fully supported. | +| Assessment/work review | Read-only/limited | Read-only/limited | Read-only/limited | Auto-scored attempts exist; manual grading/feedback persistence is not verified. | +| Discussion moderation | Deferred | Deferred | Deferred | Threads/posts lack verified class scope and moderation permissions. | +| Studio/admin links | No | No | Org admin may have organization/studio permissions separately | Teach nav does not expose Studio/Admin for teacher/instructor roles. | diff --git a/frontend/src/app/app.routes.spec.ts b/frontend/src/app/app.routes.spec.ts index c520a07..1b28b3d 100644 --- a/frontend/src/app/app.routes.spec.ts +++ b/frontend/src/app/app.routes.spec.ts @@ -10,6 +10,11 @@ import { DemoReadinessComponent } from './pages/demo-readiness/demo-readiness.co import { PublicProductsComponent } from './pages/public-products/public-products.component'; import { PublicProductDetailComponent } from './pages/public-products/public-product-detail.component'; import { RoleGuard } from './guards/role.guard'; +import { SectionsComponent } from './pages/sections/sections.component'; +import { SectionDetailComponent } from './pages/section-detail/section-detail.component'; +import { TeacherCurriculumComponent } from './pages/teacher-curriculum/teacher-curriculum.component'; +import { TeacherCoursePreviewComponent } from './pages/teacher-curriculum/teacher-course-preview.component'; +import { TeacherLearnerDetailComponent } from './pages/teacher-learner-detail/teacher-learner-detail.component'; function findRoute(routeList: Routes, path: string): Route | undefined { const exactRoute = routeList.find(candidate => candidate.path === path); @@ -90,6 +95,31 @@ describe('app routes', () => { ].forEach(path => expect(findRoute(routes, path)).withContext(path).toBeTruthy()); }); + it('adds canonical Teach aliases while preserving legacy teacher routes', () => { + [ + 'teach', + 'teach/', + 'teach/classes', + 'teach/classes/:id', + 'teach/curriculum', + 'teach/courses/:courseId/preview', + 'teach/assignments', + 'teach/learners/:learnerId', + 'home/sections', + 'home/sections/:id', + 'workspace/learners/cohorts', + 'workspace/learners/cohorts/:id', + ].forEach(path => expect(findRoute(routes, path)).withContext(path).toBeTruthy()); + + expect(findRoute(routes, 'teach/classes')?.component).toBe(SectionsComponent); + expect(findRoute(routes, 'teach/classes/:id')?.component).toBe(SectionDetailComponent); + expect(findRoute(routes, 'teach/curriculum')?.component).toBe(TeacherCurriculumComponent); + expect(findRoute(routes, 'teach/courses/:courseId/preview')?.component).toBe(TeacherCoursePreviewComponent); + expect(findRoute(routes, 'teach/learners/:learnerId')?.component).toBe(TeacherLearnerDetailComponent); + expect(findRoute(routes, 'teach/classes')?.canActivate).toContain(RoleGuard); + expect(findRoute(routes, 'teach/curriculum')?.canActivate).toContain(RoleGuard); + }); + it('maps Learner Portal V2 routes to V2 pages while reusing lesson runtime', () => { const learnRoute = findRoute(routes, 'learn'); const learnerShell = learnRoute?.children?.find(route => route.path === ''); diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 6c0c417..303aea7 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -42,6 +42,9 @@ import { DemoReadinessComponent } from './pages/demo-readiness/demo-readiness.co import { PublicProductsComponent } from './pages/public-products/public-products.component'; import { PublicProductDetailComponent } from './pages/public-products/public-product-detail.component'; import { CommercialDashboardComponent } from './pages/commercial-dashboard/commercial-dashboard.component'; +import { TeacherCurriculumComponent } from './pages/teacher-curriculum/teacher-curriculum.component'; +import { TeacherCoursePreviewComponent } from './pages/teacher-curriculum/teacher-course-preview.component'; +import { TeacherLearnerDetailComponent } from './pages/teacher-learner-detail/teacher-learner-detail.component'; const creatorRoles = ['admin', 'teacher', 'content_admin', 'org_admin', 'instructor']; const studioRoles = ['content_admin', 'org_admin']; @@ -55,6 +58,57 @@ export const routes: Routes = [ { path: 'registration', component: RegistrationComponent }, { path: 'onboarding/organization', component: OnboardingOrganizationComponent }, { path: 'access-denied', component: AccessDeniedComponent }, + { + path: 'teach', + component: HomeComponent, + canActivate: [HomeSessionGuard], + children: [ + { + path: '', + component: UserDashboardComponent, + children: [ + { path: '', component: EchoedRoleSelectorComponent }, + { + path: 'classes', + component: SectionsComponent, + canActivate: [RoleGuard], + data: { roles: ['teacher', 'instructor', 'org_admin'] } + }, + { + path: 'classes/:id', + component: SectionDetailComponent, + canActivate: [RoleGuard], + data: { roles: ['teacher', 'instructor', 'org_admin'] } + }, + { + path: 'curriculum', + component: TeacherCurriculumComponent, + canActivate: [RoleGuard], + data: { roles: ['teacher', 'instructor'] } + }, + { + path: 'courses/:courseId/preview', + component: TeacherCoursePreviewComponent, + canActivate: [RoleGuard], + data: { roles: ['teacher', 'instructor'] } + }, + { + path: 'assignments', + component: SectionsComponent, + canActivate: [RoleGuard], + data: { roles: ['teacher', 'instructor', 'org_admin'] } + }, + { + path: 'learners/:learnerId', + component: TeacherLearnerDetailComponent, + canActivate: [RoleGuard], + data: { roles: ['teacher', 'instructor', 'org_admin'] } + }, + { path: 'settings', component: PreferencesComponent }, + ] + } + ] + }, { path: 'home', component: HomeComponent, diff --git a/frontend/src/app/models/section.ts b/frontend/src/app/models/section.ts index d080fe0..2bbed8c 100644 --- a/frontend/src/app/models/section.ts +++ b/frontend/src/app/models/section.ts @@ -19,3 +19,21 @@ export interface Assignment { created_by: string; created_at: string; } + +export interface Enrollment { + id: string; + section_id: string; + user_id: string; + role_in_section: string; + status: string; + enrolled_at: string; +} + +export interface SectionSummary { + section_id: string; + totals: { + enrolled: number; + lessons_completed: number; + units_completed: number; + }; +} diff --git a/frontend/src/app/pages/section-detail/section-detail.component.html b/frontend/src/app/pages/section-detail/section-detail.component.html index a86fb51..f61c5ee 100644 --- a/frontend/src/app/pages/section-detail/section-detail.component.html +++ b/frontend/src/app/pages/section-detail/section-detail.component.html @@ -1,35 +1,210 @@ -
-
-

Section detail

-

Manage assignments and run live lesson sessions.

-
- -
-

New assignment

-
- - - - -
- -
- -
-
-

Assignments

- -
-
No assignments yet.
-
-
-

{{ assignment.target_type | titlecase }} ยท {{ assignment.target_id }}

-

Due: {{ assignment.due_at || 'Flexible' }}

-

{{ assignment.instructions }}

+
+ Back to classes + + + + + + +
+
+

Class overview

+

{{ className }}

+

{{ section.mode | titlecase }} class. Organization and section authorization remain enforced by the existing backend APIs.

+
+ Assign from curriculum +
+ + + +
+

Today

+

Class status

+
+
+ Learners + {{ rosterCount }} +
+
+ Assignments + {{ assignments.length }} +
+
+ Lessons complete + {{ completedLessons }} +
+
+ Units complete + {{ completedUnits }} +
+
+
+
+

Current assignments

+

No assignments have been created for this class.

+
    +
  • + {{ assignmentTargetLabel(assignment) }} + {{ assignment.due_at ? ('Due ' + (assignment.due_at | date:'MMM d, y, h:mm a')) : 'No due date' }} +
  • +
+
+
+

Work review

+

+ Existing assignment APIs expose assignment creation and student submission status, but no teacher review queue, + manual scoring, feedback save, or return-work endpoint is verified. +

+
+
+
+ +
+

Roster

+

Learners in this class

+ +
+
+
+

{{ learnerName(enrollment) }}

+

{{ enrollment.status | titlecase }} enrollment

+
+
+
+
Role
+
{{ enrollment.role_in_section | titlecase }}
+
+
+
Joined
+
{{ enrollment.enrolled_at | date:'MMM d, y' }}
+
+
+ View learner +
+
+
+ +
+

Assignments

+

Assign learning

+
+ + + + + +
+

Assignments apply to this class section. The current API does not support selecting individual learners during class assignment creation.

+ + +
+
+
+

{{ assignmentTargetLabel(assignment) }}

+

{{ assignment.instructions || 'No teacher instructions provided.' }}

+
+
+
+
Target
+
{{ assignment.target_type | titlecase }}
+
+
+
Due
+
{{ assignment.due_at ? (assignment.due_at | date:'MMM d, y, h:mm a') : 'Flexible' }}
+
+
+
-
-
-
+ + +
+

Progress

+

Supported class progress

+

This class view uses the section analytics summary endpoint. Learner-by-learner lesson status is available only through broader teacher summary APIs and may not be class-scoped.

+
+
+ Learners enrolled + {{ rosterCount }} +
+
+ Lessons complete + {{ completedLessons }} +
+
+ Units complete + {{ completedUnits }} +
+
+
+ +
+

Discussion

+

Class discussion entry points

+

Thread and post APIs exist, but they are not currently class-scoped or permission-scoped for teacher moderation. No class discussion feed is shown here until that backend scope is verified.

+
+ + + + diff --git a/frontend/src/app/pages/section-detail/section-detail.component.scss b/frontend/src/app/pages/section-detail/section-detail.component.scss index 5d4e87f..d99669b 100644 --- a/frontend/src/app/pages/section-detail/section-detail.component.scss +++ b/frontend/src/app/pages/section-detail/section-detail.component.scss @@ -1,3 +1,277 @@ :host { display: block; } + +.class-detail { + color: var(--ee-text, #102033); + display: grid; + gap: 1rem; + padding: clamp(1rem, 3vw, 2rem); +} + +.class-detail__back, +.class-detail a, +.class-detail button { + border-radius: var(--ee-radius-control-default, 8px); + font-weight: 850; + min-height: 2.75rem; +} + +.class-detail__back, +.class-detail a { + align-items: center; + color: var(--ee-action-primary, #0f766e); + display: inline-flex; + text-decoration: none; +} + +.class-detail__back { + text-decoration: underline; + text-underline-offset: 0.2em; +} + +.class-detail__header, +.class-detail__tabs, +.class-detail__panel, +.class-detail__row, +.class-detail__metrics article { + background: var(--ee-surface, #fffdf8); + border: 1px solid var(--ee-border, #dccdb7); + border-radius: var(--ee-radius-lg, 8px); + box-shadow: var(--ee-shadow-soft, 0 12px 30px rgba(16, 32, 51, 0.08)); +} + +.class-detail__header { + align-items: start; + display: flex; + gap: 1rem; + justify-content: space-between; + padding: clamp(1rem, 3vw, 1.5rem); +} + +.class-detail__header > a, +.class-detail__row > a { + background: var(--ee-color-action-secondary-background, #fffdf8); + border: 1px solid var(--ee-color-action-secondary-border, #cdbda3); + padding: 0.65rem 0.9rem; +} + +.class-detail__tabs { + display: flex; + gap: 0.5rem; + overflow-x: auto; + padding: 0.5rem; +} + +.class-detail__tabs button { + background: transparent; + border: 1px solid transparent; + color: var(--ee-text-soft, #40546b); + cursor: pointer; + padding: 0.65rem 0.9rem; + white-space: nowrap; +} + +.class-detail__tabs .class-detail__tab--active { + background: var(--ee-action-primary, #0f766e); + border-color: var(--ee-action-primary, #0f766e); + color: #fff; +} + +.class-detail__panel { + display: grid; + gap: 1rem; + padding: clamp(1rem, 3vw, 1.5rem); +} + +.class-detail__eyebrow, +.class-detail__metrics span, +dt, +label span { + color: var(--ee-text-muted, #5f6f7f); + font-size: 0.76rem; + font-weight: 900; + letter-spacing: 0.08em; + margin: 0; + text-transform: uppercase; +} + +h1, +h2, +h3, +p { + letter-spacing: 0; + margin: 0; +} + +h1 { + font-size: clamp(2rem, 5vw, 3rem); + line-height: 1; +} + +.class-detail__header p, +.class-detail__muted, +.class-detail__row p, +.class-detail__row span { + color: var(--ee-text-soft, #40546b); + line-height: 1.6; +} + +.class-detail__metrics { + display: grid; + gap: 1rem; + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.class-detail__metrics article { + display: grid; + gap: 0.35rem; + padding: 1rem; +} + +.class-detail__metrics strong { + color: var(--ee-action-primary, #0f766e); + font-size: 2rem; + line-height: 1; +} + +.class-detail__two-column { + display: grid; + gap: 1rem; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.class-detail__list, +.class-detail__data-list { + display: grid; + gap: 0.75rem; +} + +.class-detail__list { + list-style: none; + margin: 0; + padding: 0; +} + +.class-detail__list li { + background: var(--ee-surface-muted, #f8fafc); + border: 1px solid var(--ee-border, #dccdb7); + border-radius: 8px; + display: grid; + gap: 0.25rem; + padding: 0.75rem; +} + +.class-detail__row { + align-items: start; + display: grid; + gap: 1rem; + grid-template-columns: minmax(0, 1fr) minmax(14rem, 0.5fr) auto; + padding: 1rem; +} + +dl { + display: grid; + gap: 0.75rem; + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin: 0; +} + +dd { + margin: 0; +} + +.class-detail__assignment-form { + display: grid; + gap: 0.85rem; + grid-template-columns: minmax(10rem, 0.35fr) minmax(0, 1fr) minmax(12rem, 0.45fr); +} + +.class-detail__wide { + grid-column: 1 / -1; +} + +label { + display: grid; + gap: 0.35rem; +} + +input, +select, +textarea { + background: #fff; + border: 1px solid var(--ee-border, #dccdb7); + border-radius: var(--ee-radius-control-default, 8px); + color: var(--ee-text, #102033); + min-height: 2.75rem; + padding: 0.65rem 0.75rem; +} + +textarea { + resize: vertical; +} + +.class-detail__assignment-form button { + align-self: end; + background: var(--ee-action-primary, #0f766e); + border: 1px solid var(--ee-action-primary, #0f766e); + color: #fff; + cursor: pointer; + padding: 0.65rem 0.9rem; +} + +.class-detail__assignment-form button:disabled { + cursor: wait; + opacity: 0.68; +} + +.class-detail__error { + background: var(--ee-color-status-danger-background, #fff1f3); + border: 1px solid var(--ee-color-status-danger-border, #ffd9df); + border-radius: 8px; + color: var(--ee-color-status-danger-text, #9f1239); + padding: 0.75rem; +} + +a:focus-visible, +button:focus-visible, +input:focus-visible, +select:focus-visible, +textarea:focus-visible { + outline: 3px solid var(--ee-focus-ring, #0f766e); + outline-offset: 3px; +} + +@media (max-width: 980px) { + .class-detail__header, + .class-detail__two-column { + display: grid; + } + + .class-detail__metrics { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .class-detail__row, + .class-detail__assignment-form { + grid-template-columns: 1fr; + } + + .class-detail__header > a, + .class-detail__row > a, + .class-detail__assignment-form button { + justify-content: center; + width: 100%; + } +} + +@media (max-width: 520px) { + .class-detail { + padding: 0.75rem; + } + + .class-detail__metrics, + dl { + grid-template-columns: 1fr; + } +} diff --git a/frontend/src/app/pages/section-detail/section-detail.component.ts b/frontend/src/app/pages/section-detail/section-detail.component.ts index 54e1aa2..ff1b208 100644 --- a/frontend/src/app/pages/section-detail/section-detail.component.ts +++ b/frontend/src/app/pages/section-detail/section-detail.component.ts @@ -1,61 +1,201 @@ import { Component, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, RouterLink } from '@angular/router'; +import { forkJoin, of } from 'rxjs'; +import { catchError, finalize } from 'rxjs/operators'; + +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 { Course } from '../../models/course'; +import { Assignment, Enrollment, Section, SectionSummary } from '../../models/section'; +import { User } from '../../models/user'; +import { CoursesService } from '../../services/courses.service'; import { SectionsService } from '../../services/sections.service'; -import { Assignment } from '../../models/section'; import { ToastService } from '../../services/toast.service'; +import { UsersService } from '../../services/users.service'; + +type ClassTab = 'overview' | 'roster' | 'assignments' | 'progress' | 'discussion'; @Component({ selector: 'app-section-detail', standalone: true, - imports: [CommonModule, FormsModule], + imports: [ + CommonModule, + FormsModule, + RouterLink, + EchoConfirmationDialogComponent, + EchoLoadingStateComponent, + EchoStatePanelComponent, + ], templateUrl: './section-detail.component.html', styleUrl: './section-detail.component.scss' }) export class SectionDetailComponent implements OnInit { sectionId = ''; + section?: Section; assignments: Assignment[] = []; + roster: Enrollment[] = []; + students: User[] = []; + courses: Course[] = []; + summary?: SectionSummary; + activeTab: ClassTab = 'overview'; targetType = 'lesson'; targetId = ''; dueAt = ''; instructions = ''; + loading = true; + savingAssignment = false; + loadError = ''; + assignmentError = ''; + confirmationOpen = false; + + readonly tabs: { id: ClassTab; label: string }[] = [ + { id: 'overview', label: 'Overview' }, + { id: 'roster', label: 'Roster' }, + { id: 'assignments', label: 'Assignments' }, + { id: 'progress', label: 'Progress' }, + { id: 'discussion', label: 'Discussion' }, + ]; constructor( private route: ActivatedRoute, private sectionsService: SectionsService, + private coursesService: CoursesService, + private usersService: UsersService, private toastService: ToastService ) {} ngOnInit(): void { this.sectionId = this.route.snapshot.paramMap.get('id') || ''; - this.loadAssignments(); + this.loadClass(); + } + + get className(): string { + return this.section?.name || 'Class detail'; + } + + get rosterCount(): number { + return this.summary?.totals.enrolled ?? this.roster.length; + } + + get completedLessons(): number { + return this.summary?.totals.lessons_completed ?? 0; + } + + get completedUnits(): number { + return this.summary?.totals.units_completed ?? 0; + } + + get upcomingAssignments(): Assignment[] { + return [...this.assignments].sort((left, right) => { + const leftDate = left.due_at ? new Date(left.due_at).getTime() : Number.MAX_SAFE_INTEGER; + const rightDate = right.due_at ? new Date(right.due_at).getTime() : Number.MAX_SAFE_INTEGER; + return leftDate - rightDate; + }); } - loadAssignments(): void { + loadClass(): void { if (!this.sectionId) { + this.loading = false; + this.loadError = 'This class link is missing a section id.'; return; } - this.sectionsService.getAssignments(this.sectionId).subscribe(assignments => { - this.assignments = assignments; + + this.loading = true; + this.loadError = ''; + forkJoin({ + sections: this.sectionsService.listSections(), + assignments: this.sectionsService.getAssignments(this.sectionId).pipe(catchError(() => of([] as Assignment[]))), + roster: this.sectionsService.getRoster(this.sectionId).pipe(catchError(() => of([] as Enrollment[]))), + summary: this.sectionsService.getSectionSummary(this.sectionId).pipe(catchError(() => of(undefined))), + students: this.usersService.getStudents().pipe(catchError(() => of([] as User[]))), + courses: this.coursesService.getCourses().pipe(catchError(() => of([] as Course[]))), + }).subscribe({ + next: ({ sections, assignments, roster, summary, students, courses }) => { + this.section = sections.find(section => section.id === this.sectionId); + this.assignments = assignments; + this.roster = roster; + this.summary = summary; + this.students = students; + this.courses = courses; + this.loading = false; + if (!this.section) { + this.loadError = 'This class is not available in your authorized class list.'; + } + }, + error: () => { + this.loading = false; + this.loadError = 'We could not load this class. No roster or assignment data was changed.'; + }, }); } + setTab(tab: ClassTab): void { + this.activeTab = tab; + } + + learnerName(enrollment: Enrollment): string { + const student = this.students.find(row => row.id === enrollment.user_id); + if (!student) { + return 'Learner profile unavailable'; + } + return `${student.firstname || ''} ${student.lastname || ''}`.trim() || student.username || 'Learner'; + } + + assignmentTargetLabel(assignment: Assignment): string { + const course = this.courses.find(row => row.id === assignment.target_id); + if (course) { + return course.title; + } + return `${this.titleCase(assignment.target_type)} ${assignment.target_id}`; + } + + requestAssignmentCreate(): void { + this.assignmentError = ''; + if (!this.targetId.trim()) { + this.assignmentError = 'Choose or enter the learning target before assigning.'; + return; + } + this.confirmationOpen = true; + } + + cancelAssignmentCreate(): void { + if (!this.savingAssignment) { + this.confirmationOpen = false; + } + } + createAssignment(): void { - if (!this.sectionId || !this.targetId) { + if (!this.sectionId || !this.targetId.trim()) { return; } + this.savingAssignment = true; + this.assignmentError = ''; this.sectionsService.createAssignment(this.sectionId, { target_type: this.targetType, - target_id: this.targetId, + target_id: this.targetId.trim(), due_at: this.dueAt || null, - instructions: this.instructions - }).subscribe(() => { - this.toastService.show('Assignment created!', 'success'); - this.targetId = ''; - this.dueAt = ''; - this.instructions = ''; - this.loadAssignments(); + instructions: this.instructions.trim() || null, + }).pipe(finalize(() => { + this.savingAssignment = false; + })).subscribe({ + next: () => { + this.toastService.show('Assignment created.', 'success'); + this.confirmationOpen = false; + this.targetId = ''; + this.dueAt = ''; + this.instructions = ''; + this.loadClass(); + }, + error: (error) => { + this.assignmentError = error?.error?.detail || 'We could not create this assignment. No learners were assigned.'; + }, }); } + + private titleCase(value: string): string { + return value.replace(/[_-]/g, ' ').replace(/\b\w/g, char => char.toUpperCase()); + } } diff --git a/frontend/src/app/pages/sections/sections.component.html b/frontend/src/app/pages/sections/sections.component.html index 6763cb9..a871e43 100644 --- a/frontend/src/app/pages/sections/sections.component.html +++ b/frontend/src/app/pages/sections/sections.component.html @@ -1,32 +1,102 @@ -
-
-

Sections & classrooms

-

Create cohorts, assign lessons, and monitor progress.

-
+
+
+
+

Teach

+

My classes

+

View classes and sections you are authorized to manage. Learner counts come from the existing section summary API.

+
+ Browse curriculum +
+ +
+
+

Create class

+

New class section

+

The current backend creates a class from a course version ID. This does not enroll learners automatically.

+
+
+ + + + +
+

Use a course version that already exists in the current organization context.

+ +
-
-

New section

-
- - - - +
+
+
+

Authorized classes

+

Class list

+
+
-
- +
+ diff --git a/frontend/src/app/pages/sections/sections.component.scss b/frontend/src/app/pages/sections/sections.component.scss index 5d4e87f..fc8c4bc 100644 --- a/frontend/src/app/pages/sections/sections.component.scss +++ b/frontend/src/app/pages/sections/sections.component.scss @@ -1,3 +1,205 @@ :host { display: block; } + +.teach-classes { + color: var(--ee-text, #102033); + display: grid; + gap: 1rem; + padding: clamp(1rem, 3vw, 2rem); +} + +.teach-classes__header, +.teach-classes__panel, +.teach-classes__card { + background: var(--ee-surface, #fffdf8); + border: 1px solid var(--ee-border, #dccdb7); + border-radius: var(--ee-radius-lg, 8px); + box-shadow: var(--ee-shadow-soft, 0 12px 30px rgba(16, 32, 51, 0.08)); +} + +.teach-classes__header, +.teach-classes__panel-heading { + align-items: start; + display: flex; + gap: 1rem; + justify-content: space-between; +} + +.teach-classes__header, +.teach-classes__panel { + padding: clamp(1rem, 3vw, 1.5rem); +} + +.teach-classes__panel { + display: grid; + gap: 1rem; +} + +.teach-classes__eyebrow { + color: var(--ee-text-muted, #5f6f7f); + font-size: 0.76rem; + font-weight: 900; + letter-spacing: 0.12em; + margin: 0; + text-transform: uppercase; +} + +h1, +h2, +h3, +p { + letter-spacing: 0; + margin: 0; +} + +h1 { + font-size: clamp(2rem, 5vw, 3rem); + line-height: 1; +} + +.teach-classes__header p, +.teach-classes__panel p, +.teach-classes__muted { + color: var(--ee-text-soft, #40546b); + line-height: 1.6; +} + +.teach-classes a, +.teach-classes button { + border-radius: var(--ee-radius-control-default, 8px); + font-weight: 850; + min-height: 2.75rem; +} + +.teach-classes__header > a, +.teach-classes__card { + color: var(--ee-action-primary, #0f766e); + text-decoration: none; +} + +.teach-classes__header > a { + align-items: center; + background: var(--ee-color-action-secondary-background, #fffdf8); + border: 1px solid var(--ee-color-action-secondary-border, #cdbda3); + display: inline-flex; + padding: 0.65rem 0.9rem; +} + +.teach-classes__form { + display: grid; + gap: 0.85rem; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(10rem, 0.35fr) auto; +} + +label { + display: grid; + gap: 0.35rem; +} + +label span, +dt { + color: var(--ee-text-muted, #5f6f7f); + font-size: 0.8rem; + font-weight: 850; +} + +input, +select { + background: #fff; + border: 1px solid var(--ee-border, #dccdb7); + border-radius: var(--ee-radius-control-default, 8px); + color: var(--ee-text, #102033); + min-height: 2.75rem; + padding: 0.65rem 0.75rem; +} + +button { + align-self: end; + background: var(--ee-action-primary, #0f766e); + border: 1px solid var(--ee-action-primary, #0f766e); + color: #fff; + cursor: pointer; + padding: 0.65rem 0.9rem; +} + +button:disabled { + cursor: wait; + opacity: 0.68; +} + +input:focus-visible, +select:focus-visible, +button:focus-visible, +a:focus-visible { + outline: 3px solid var(--ee-focus-ring, #0f766e); + outline-offset: 3px; +} + +.teach-classes__error { + background: var(--ee-color-status-danger-background, #fff1f3); + border: 1px solid var(--ee-color-status-danger-border, #ffd9df); + border-radius: 8px; + color: var(--ee-color-status-danger-text, #9f1239); + padding: 0.75rem; +} + +.teach-classes__search { + min-width: min(20rem, 100%); +} + +.teach-classes__grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.teach-classes__card { + display: grid; + gap: 0.75rem; + padding: 1rem; +} + +.teach-classes__card > span { + color: var(--ee-text-muted, #5f6f7f); + font-size: 0.8rem; + font-weight: 850; +} + +dl { + display: grid; + gap: 0.75rem; + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin: 0; +} + +dd { + color: var(--ee-action-primary, #0f766e); + font-size: 1.4rem; + font-weight: 900; + margin: 0; +} + +@media (max-width: 900px) { + .teach-classes__header, + .teach-classes__panel-heading { + display: grid; + } + + .teach-classes__form, + .teach-classes__grid { + grid-template-columns: 1fr; + } + + button, + .teach-classes__header > a { + justify-content: center; + width: 100%; + } +} + +@media (max-width: 520px) { + .teach-classes { + padding: 0.75rem; + } +} diff --git a/frontend/src/app/pages/sections/sections.component.ts b/frontend/src/app/pages/sections/sections.component.ts index 983b2e5..22df78e 100644 --- a/frontend/src/app/pages/sections/sections.component.ts +++ b/frontend/src/app/pages/sections/sections.component.ts @@ -1,23 +1,34 @@ import { Component, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; -import { SectionsService } from '../../services/sections.service'; -import { Section } from '../../models/section'; import { RouterModule } from '@angular/router'; +import { forkJoin, of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +import { EchoLoadingStateComponent } from '../../components/echo-loading-state/echo-loading-state.component'; +import { EchoStatePanelComponent } from '../../components/echo-state-panel/echo-state-panel.component'; +import { Section, SectionSummary } from '../../models/section'; +import { SectionsService } from '../../services/sections.service'; import { ToastService } from '../../services/toast.service'; @Component({ selector: 'app-sections', standalone: true, - imports: [CommonModule, FormsModule, RouterModule], + imports: [CommonModule, FormsModule, RouterModule, EchoLoadingStateComponent, EchoStatePanelComponent], templateUrl: './sections.component.html', styleUrl: './sections.component.scss' }) export class SectionsComponent implements OnInit { sections: Section[] = []; + summaries: Record = {}; name = ''; courseVersionId = ''; mode = 'remote'; + searchTerm = ''; + loading = true; + creating = false; + loadError = ''; + formError = ''; constructor(private sectionsService: SectionsService, private toastService: ToastService) {} @@ -25,21 +36,88 @@ export class SectionsComponent implements OnInit { this.loadSections(); } + get filteredSections(): Section[] { + const search = this.searchTerm.trim().toLowerCase(); + if (!search) { + return this.sections; + } + return this.sections.filter(section => + section.name.toLowerCase().includes(search) || + section.mode.toLowerCase().includes(search) + ); + } + loadSections(): void { - this.sectionsService.listSections().subscribe(sections => { - this.sections = sections; + this.loading = true; + this.loadError = ''; + this.sectionsService.listSections().subscribe({ + next: (sections) => { + this.sections = sections; + this.loading = false; + this.loadSummaries(); + }, + error: () => { + this.sections = []; + this.summaries = {}; + this.loading = false; + this.loadError = 'We could not load your classes. No class data was changed.'; + }, }); } createSection(): void { - if (!this.name || !this.courseVersionId) { + this.formError = ''; + if (!this.name.trim() || !this.courseVersionId.trim()) { + this.formError = 'Class name and course version ID are required by the current section API.'; return; } - this.sectionsService.createSection({ name: this.name, course_version_id: this.courseVersionId, mode: this.mode }).subscribe(() => { - this.toastService.show('Section created!', 'success'); - this.name = ''; - this.courseVersionId = ''; - this.loadSections(); + + this.creating = true; + this.sectionsService.createSection({ + name: this.name.trim(), + course_version_id: this.courseVersionId.trim(), + mode: this.mode, + }).subscribe({ + next: () => { + this.toastService.show('Class created.', 'success'); + this.name = ''; + this.courseVersionId = ''; + this.mode = 'remote'; + this.creating = false; + this.loadSections(); + }, + error: (error) => { + this.creating = false; + this.formError = error?.error?.detail || 'We could not create this class. No learners were enrolled.'; + }, + }); + } + + learnerCount(section: Section): number { + return this.summaries[section.id]?.totals.enrolled ?? 0; + } + + lessonCount(section: Section): number { + return this.summaries[section.id]?.totals.lessons_completed ?? 0; + } + + private loadSummaries(): void { + if (this.sections.length === 0) { + this.summaries = {}; + return; + } + + forkJoin( + this.sections.map(section => + this.sectionsService.getSectionSummary(section.id).pipe(catchError(() => of(undefined))) + ) + ).subscribe((summaries) => { + this.summaries = summaries.reduce((map, summary) => { + if (summary) { + map[summary.section_id] = summary; + } + return map; + }, {} as Record); }); } } diff --git a/frontend/src/app/pages/teacher-curriculum/teacher-course-preview.component.scss b/frontend/src/app/pages/teacher-curriculum/teacher-course-preview.component.scss new file mode 100644 index 0000000..0b65fc8 --- /dev/null +++ b/frontend/src/app/pages/teacher-curriculum/teacher-course-preview.component.scss @@ -0,0 +1,104 @@ +:host { + display: block; +} + +.teacher-preview { + color: var(--ee-text, #102033); + display: grid; + gap: 1rem; + padding: clamp(1rem, 3vw, 2rem); +} + +.teacher-preview__back { + color: var(--ee-action-primary, #0f766e); + font-weight: 850; + text-decoration: underline; + text-underline-offset: 0.2em; +} + +.teacher-preview__header, +.teacher-preview__panel { + background: var(--ee-surface, #fffdf8); + border: 1px solid var(--ee-border, #dccdb7); + border-radius: var(--ee-radius-lg, 8px); + box-shadow: var(--ee-shadow-soft, 0 12px 30px rgba(16, 32, 51, 0.08)); + padding: clamp(1rem, 3vw, 1.5rem); +} + +.teacher-preview__header { + display: grid; + gap: 1rem; + grid-template-columns: minmax(0, 1fr) minmax(18rem, 0.38fr); +} + +.teacher-preview__header aside { + background: var(--ee-surface-muted, #f8fafc); + border: 1px solid var(--ee-border, #dccdb7); + border-radius: 8px; + display: grid; + gap: 0.5rem; + padding: 1rem; +} + +.teacher-preview__panel { + display: grid; + gap: 0.75rem; +} + +.teacher-preview__eyebrow, +dt { + color: var(--ee-text-muted, #5f6f7f); + font-size: 0.76rem; + font-weight: 900; + letter-spacing: 0.1em; + margin: 0; + text-transform: uppercase; +} + +h1, +h2, +p { + letter-spacing: 0; + margin: 0; +} + +h1 { + font-size: clamp(2rem, 5vw, 3.4rem); + line-height: 1; +} + +.teacher-preview p { + color: var(--ee-text-soft, #40546b); + line-height: 1.6; +} + +dl { + display: flex; + flex-wrap: wrap; + gap: 1rem; + margin: 0; +} + +dd { + color: var(--ee-action-primary, #0f766e); + font-size: 1.4rem; + font-weight: 900; + margin: 0; +} + +a:focus-visible { + outline: 3px solid var(--ee-focus-ring, #0f766e); + outline-offset: 3px; +} + +@media (max-width: 900px) { + .teacher-preview__header { + grid-template-columns: 1fr; + } +} + +@media (max-width: 520px) { + .teacher-preview { + padding: 0.75rem; + } +} diff --git a/frontend/src/app/pages/teacher-curriculum/teacher-course-preview.component.ts b/frontend/src/app/pages/teacher-curriculum/teacher-course-preview.component.ts new file mode 100644 index 0000000..ddfe30b --- /dev/null +++ b/frontend/src/app/pages/teacher-curriculum/teacher-course-preview.component.ts @@ -0,0 +1,190 @@ +import { CommonModule } from '@angular/common'; +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute, RouterLink } from '@angular/router'; + +import { + StudentCurriculumComponent, + StudentCurriculumLesson, + StudentCurriculumUnit, +} from '../../components/student-curriculum/student-curriculum.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 { Unit } from '../../models/unit'; +import { CoursesService } from '../../services/courses.service'; + +@Component({ + selector: 'app-teacher-course-preview', + standalone: true, + imports: [CommonModule, RouterLink, StudentCurriculumComponent, EchoLoadingStateComponent, EchoStatePanelComponent], + template: ` +
+ Back to curriculum + + + + + +
+
+

Teacher preview mode

+

{{ course.title }}

+

{{ course.description || 'No course description available.' }}

+
+ +
+ +
+

Course purpose

+

What learners will see

+

{{ course.learning_objectives || 'Learning objectives are not available from the current course data.' }}

+
+
+
Units
+
{{ unitCount }}
+
+
+
Lessons
+
{{ lessonCount }}
+
+
+
+ + + +
+

Assessments and resources

+

Supported preview detail

+

+ Lesson activities, discussion prompts, teacher notes, sources, and assessment references appear where they are included in the current course and lesson payloads. + This page does not call the student start-course API. +

+

{{ previewMessage }}

+
+
+
+ `, + styleUrl: './teacher-course-preview.component.scss', +}) +export class TeacherCoursePreviewComponent implements OnInit { + courseId = ''; + course?: Course; + loading = true; + loadError = ''; + previewMessage = ''; + curriculumUnits: StudentCurriculumUnit[] = []; + + constructor( + private route: ActivatedRoute, + private coursesService: CoursesService, + ) {} + + ngOnInit(): void { + this.courseId = this.route.snapshot.paramMap.get('courseId') || ''; + this.loadCourse(); + } + + get unitCount(): number { + return this.orderedUnits.length; + } + + get lessonCount(): number { + return this.orderedUnits.reduce((count, unit) => count + this.orderedLessons(unit).length, 0); + } + + get curriculumSummaryLabel(): string { + return `${this.unitCount} unit${this.unitCount === 1 ? '' : 's'}, ${this.lessonCount} lesson${this.lessonCount === 1 ? '' : 's'} in preview`; + } + + loadCourse(): void { + if (!this.courseId) { + this.loading = false; + this.loadError = 'This preview link is missing a course id.'; + return; + } + this.loading = true; + this.loadError = ''; + this.previewMessage = ''; + this.coursesService.getCourseById(this.courseId).subscribe({ + next: (course) => { + this.course = course as unknown as Course; + this.curriculumUnits = this.buildCurriculumUnits(); + this.loading = false; + }, + error: (error) => { + this.course = undefined; + this.curriculumUnits = []; + this.loading = false; + this.loadError = error?.status === 404 + ? 'This course could not be found.' + : 'We could not load the course preview. No learner progress was changed.'; + }, + }); + } + + notePreviewOnly(lesson: StudentCurriculumLesson): void { + this.previewMessage = `${lesson.title} is shown in teacher preview only. Open the lesson runtime only through supported learner or staff lesson routes.`; + } + + 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; + return leftOrder === rightOrder ? left.title.localeCompare(right.title) : leftOrder - rightOrder; + }); + } + + private buildCurriculumUnits(): StudentCurriculumUnit[] { + return this.orderedUnits.map((unit, index) => ({ + id: unit.id, + title: unit.title, + order: unit.order ?? index + 1, + progressLabel: 'Preview', + lessons: this.orderedLessons(unit).map(lesson => this.mapLesson(lesson)), + })); + } + + private mapLesson(lesson: Lesson): StudentCurriculumLesson { + return { + id: lesson.id, + title: lesson.title, + objective: lesson.objective || lesson.learning_objectives, + durationMinutes: lesson.duration_minutes, + activityCount: lesson.activities?.length || 0, + state: 'included', + stateLabel: 'Preview', + stateDescription: 'Teacher preview only. No learner progress will be recorded.', + canOpen: true, + }; + } + + 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; + return leftOrder === rightOrder ? left.title.localeCompare(right.title) : leftOrder - rightOrder; + }); + } +} diff --git a/frontend/src/app/pages/teacher-curriculum/teacher-curriculum.component.scss b/frontend/src/app/pages/teacher-curriculum/teacher-curriculum.component.scss new file mode 100644 index 0000000..7c67cb4 --- /dev/null +++ b/frontend/src/app/pages/teacher-curriculum/teacher-curriculum.component.scss @@ -0,0 +1,141 @@ +:host { + display: block; +} + +.teacher-curriculum { + color: var(--ee-text, #102033); + display: grid; + gap: 1rem; + padding: clamp(1rem, 3vw, 2rem); +} + +.teacher-curriculum__header, +.teacher-curriculum__panel, +.teacher-curriculum__card { + background: var(--ee-surface, #fffdf8); + border: 1px solid var(--ee-border, #dccdb7); + border-radius: var(--ee-radius-lg, 8px); + box-shadow: var(--ee-shadow-soft, 0 12px 30px rgba(16, 32, 51, 0.08)); + padding: clamp(1rem, 3vw, 1.5rem); +} + +.teacher-curriculum__header { + align-items: start; + display: flex; + gap: 1rem; + justify-content: space-between; +} + +.teacher-curriculum__eyebrow, +label span, +dt { + color: var(--ee-text-muted, #5f6f7f); + font-size: 0.76rem; + font-weight: 900; + letter-spacing: 0.1em; + margin: 0; + text-transform: uppercase; +} + +h1, +h2, +p { + letter-spacing: 0; + margin: 0; +} + +h1 { + font-size: clamp(2rem, 5vw, 3rem); + line-height: 1; +} + +.teacher-curriculum__header p, +.teacher-curriculum__card p { + color: var(--ee-text-soft, #40546b); + line-height: 1.6; +} + +a { + align-items: center; + background: var(--ee-color-action-secondary-background, #fffdf8); + border: 1px solid var(--ee-color-action-secondary-border, #cdbda3); + border-radius: var(--ee-radius-control-default, 8px); + color: var(--ee-action-primary, #0f766e); + display: inline-flex; + font-weight: 850; + min-height: 2.75rem; + padding: 0.65rem 0.9rem; + text-decoration: none; +} + +label { + display: grid; + gap: 0.35rem; +} + +input { + background: #fff; + border: 1px solid var(--ee-border, #dccdb7); + border-radius: var(--ee-radius-control-default, 8px); + color: var(--ee-text, #102033); + min-height: 2.75rem; + padding: 0.65rem 0.75rem; +} + +a:focus-visible, +input:focus-visible { + outline: 3px solid var(--ee-focus-ring, #0f766e); + outline-offset: 3px; +} + +.teacher-curriculum__grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.teacher-curriculum__card { + display: grid; + gap: 0.85rem; +} + +dl { + display: grid; + gap: 0.75rem; + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin: 0; +} + +dd { + color: var(--ee-action-primary, #0f766e); + font-size: 1.4rem; + font-weight: 900; + margin: 0; +} + +.teacher-curriculum__actions { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; +} + +@media (max-width: 900px) { + .teacher-curriculum__header { + display: grid; + } + + .teacher-curriculum__grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 520px) { + .teacher-curriculum { + padding: 0.75rem; + } + + a { + justify-content: center; + width: 100%; + } +} diff --git a/frontend/src/app/pages/teacher-curriculum/teacher-curriculum.component.ts b/frontend/src/app/pages/teacher-curriculum/teacher-curriculum.component.ts new file mode 100644 index 0000000..b91d54e --- /dev/null +++ b/frontend/src/app/pages/teacher-curriculum/teacher-curriculum.component.ts @@ -0,0 +1,137 @@ +import { CommonModule } from '@angular/common'; +import { Component, OnInit } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +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 { Course } from '../../models/course'; +import { CoursesService } from '../../services/courses.service'; + +@Component({ + selector: 'app-teacher-curriculum', + standalone: true, + imports: [CommonModule, FormsModule, RouterLink, EchoLoadingStateComponent, EchoStatePanelComponent], + template: ` +
+
+
+

Teach

+

Browse curriculum

+

Find courses, preview the learner path, and choose learning to assign from existing course data.

+
+ My classes +
+ +
+
+

Filter

+

Search available courses

+
+ +
+ + + + + + +
+
+

Course

+

{{ course.title }}

+

{{ course.description || 'No description available.' }}

+
+
+
Units
+
{{ course.units?.length || 0 }}
+
+
+
Lessons
+
{{ lessonCount(course) }}
+
+
+

{{ course.learning_objectives }}

+ +
+
+
+ `, + styleUrl: './teacher-curriculum.component.scss', +}) +export class TeacherCurriculumComponent implements OnInit { + courses: Course[] = []; + searchTerm = ''; + loading = true; + loadError = ''; + + constructor(private coursesService: CoursesService) {} + + ngOnInit(): void { + this.loadCourses(); + } + + get filteredCourses(): Course[] { + const search = this.searchTerm.trim().toLowerCase(); + if (!search) { + return this.courses; + } + return this.courses.filter(course => + course.title.toLowerCase().includes(search) || + (course.description || '').toLowerCase().includes(search) || + (course.learning_objectives || '').toLowerCase().includes(search) + ); + } + + loadCourses(): void { + this.loading = true; + this.loadError = ''; + this.coursesService.getCourses().subscribe({ + next: (courses) => { + this.courses = courses; + this.loading = false; + }, + error: () => { + this.courses = []; + this.loading = false; + this.loadError = 'We could not load curriculum. No course or learner progress was changed.'; + }, + }); + } + + lessonCount(course: Course): number { + return (course.units || []).reduce((count, unit) => count + (unit.lessons?.length || 0), 0); + } +} diff --git a/frontend/src/app/pages/teacher-learner-detail/teacher-learner-detail.component.scss b/frontend/src/app/pages/teacher-learner-detail/teacher-learner-detail.component.scss new file mode 100644 index 0000000..5531d82 --- /dev/null +++ b/frontend/src/app/pages/teacher-learner-detail/teacher-learner-detail.component.scss @@ -0,0 +1,93 @@ +:host { + display: block; +} + +.teacher-learner { + color: var(--ee-text, #102033); + display: grid; + gap: 1rem; + padding: clamp(1rem, 3vw, 2rem); +} + +.teacher-learner__back { + color: var(--ee-action-primary, #0f766e); + font-weight: 850; + text-decoration: underline; + text-underline-offset: 0.2em; +} + +.teacher-learner__header, +.teacher-learner__panel, +.teacher-learner__states article { + background: var(--ee-surface, #fffdf8); + border: 1px solid var(--ee-border, #dccdb7); + border-radius: var(--ee-radius-lg, 8px); + box-shadow: var(--ee-shadow-soft, 0 12px 30px rgba(16, 32, 51, 0.08)); + padding: clamp(1rem, 3vw, 1.5rem); +} + +.teacher-learner__panel { + display: grid; + gap: 1rem; +} + +.teacher-learner__eyebrow, +dt { + color: var(--ee-text-muted, #5f6f7f); + font-size: 0.76rem; + font-weight: 900; + letter-spacing: 0.1em; + margin: 0; + text-transform: uppercase; +} + +h1, +h2, +p { + letter-spacing: 0; + margin: 0; +} + +h1 { + font-size: clamp(2rem, 5vw, 3rem); + line-height: 1; +} + +.teacher-learner p { + color: var(--ee-text-soft, #40546b); + line-height: 1.6; +} + +dl, +.teacher-learner__states { + display: grid; + gap: 1rem; + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin: 0; +} + +.teacher-learner__states { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +dd { + margin: 0; +} + +a:focus-visible { + outline: 3px solid var(--ee-focus-ring, #0f766e); + outline-offset: 3px; +} + +@media (max-width: 820px) { + dl, + .teacher-learner__states { + grid-template-columns: 1fr; + } +} + +@media (max-width: 520px) { + .teacher-learner { + padding: 0.75rem; + } +} diff --git a/frontend/src/app/pages/teacher-learner-detail/teacher-learner-detail.component.ts b/frontend/src/app/pages/teacher-learner-detail/teacher-learner-detail.component.ts new file mode 100644 index 0000000..fd73c67 --- /dev/null +++ b/frontend/src/app/pages/teacher-learner-detail/teacher-learner-detail.component.ts @@ -0,0 +1,153 @@ +import { CommonModule } from '@angular/common'; +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute, RouterLink } from '@angular/router'; +import { forkJoin, of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +import { EchoLoadingStateComponent } from '../../components/echo-loading-state/echo-loading-state.component'; +import { EchoStatePanelComponent } from '../../components/echo-state-panel/echo-state-panel.component'; +import { Enrollment, Section } from '../../models/section'; +import { User } from '../../models/user'; +import { SectionsService } from '../../services/sections.service'; +import { UsersService } from '../../services/users.service'; + +@Component({ + selector: 'app-teacher-learner-detail', + standalone: true, + imports: [CommonModule, RouterLink, EchoLoadingStateComponent, EchoStatePanelComponent], + template: ` +
+ Back to class + + + + + +
+
+

Learner detail

+

{{ learnerName }}

+

{{ section?.name || 'Class context verified from roster' }}

+
+
+ +
+

Current learning

+

Class-scoped information

+
+
+
Enrollment status
+
{{ enrollment?.status | titlecase }}
+
+
+
Role in class
+
{{ enrollment?.role_in_section | titlecase }}
+
+
+
Joined
+
{{ enrollment?.enrolled_at ? (enrollment?.enrolled_at | date:'MMM d, y') : 'Unavailable' }}
+
+
+
+ +
+

Progress and assessment

+

Supported states

+
+
+ Assignment status +

Unavailable in this detail view. The current assignment API does not expose per-learner assignment state to teachers.

+
+
+ Course progress +

Use the Teach overview for existing teacher summary rows. A class-scoped learner progress endpoint is not verified.

+
+
+ Assessment results +

Auto-scored assessment reporting endpoints exist, but this page does not infer assessment scope without a course, unit, lesson, or program target.

+
+
+ Feedback history +

Feedback unavailable. No teacher feedback persistence endpoint is verified.

+
+
+
+
+
+ `, + styleUrl: './teacher-learner-detail.component.scss', +}) +export class TeacherLearnerDetailComponent implements OnInit { + learnerId = ''; + sectionId = ''; + learner?: User; + section?: Section; + enrollment?: Enrollment; + loading = true; + loadError = ''; + + constructor( + private route: ActivatedRoute, + private sectionsService: SectionsService, + private usersService: UsersService, + ) {} + + ngOnInit(): void { + this.learnerId = this.route.snapshot.paramMap.get('learnerId') || ''; + this.sectionId = this.route.snapshot.queryParamMap.get('sectionId') || ''; + this.loadLearner(); + } + + get learnerName(): string { + if (!this.learner) { + return 'Learner'; + } + return `${this.learner.firstname || ''} ${this.learner.lastname || ''}`.trim() || this.learner.username || 'Learner'; + } + + loadLearner(): void { + if (!this.learnerId || !this.sectionId) { + this.loading = false; + this.loadError = 'Open a learner from a class roster so class authorization can be verified.'; + return; + } + + this.loading = true; + this.loadError = ''; + forkJoin({ + sections: this.sectionsService.listSections(), + roster: this.sectionsService.getRoster(this.sectionId), + students: this.usersService.getStudents().pipe(catchError(() => of([] as User[]))), + }).subscribe({ + next: ({ sections, roster, students }) => { + this.section = sections.find(section => section.id === this.sectionId); + this.enrollment = roster.find(row => row.user_id === this.learnerId); + this.learner = students.find(student => student.id === this.learnerId); + this.loading = false; + if (!this.section || !this.enrollment) { + this.loadError = 'This learner is not available in the selected authorized class roster.'; + return; + } + if (!this.learner) { + this.loadError = 'The learner enrollment exists, but the authorized student profile list did not include profile details.'; + } + }, + error: () => { + this.loading = false; + this.loadError = 'We could not verify this learner against the class roster.'; + }, + }); + } +} diff --git a/frontend/src/app/pages/user-dashboard/echoed-role-selector/echoed-role-selector.component.html b/frontend/src/app/pages/user-dashboard/echoed-role-selector/echoed-role-selector.component.html index e6cc2d9..2099b4c 100644 --- a/frontend/src/app/pages/user-dashboard/echoed-role-selector/echoed-role-selector.component.html +++ b/frontend/src/app/pages/user-dashboard/echoed-role-selector/echoed-role-selector.component.html @@ -1,8 +1,8 @@ - +

Loading user information...

- \ No newline at end of file + diff --git a/frontend/src/app/pages/user-dashboard/teacher-view/teacher-view.component.html b/frontend/src/app/pages/user-dashboard/teacher-view/teacher-view.component.html index b9da936..5448a8c 100644 --- a/frontend/src/app/pages/user-dashboard/teacher-view/teacher-view.component.html +++ b/frontend/src/app/pages/user-dashboard/teacher-view/teacher-view.component.html @@ -1,546 +1,54 @@ -
-
-
-
-
-
-

Teacher Control Center

-

Welcome, {{userInfo.fullname}}

-

- Monitor the flagship class story, review governance signals, and keep the live demo on a stable path. -

-
- Demo ready -
- -
-
-

Active courses

-

{{ courses.length }}

-
-
-

In progress lessons

-

{{ inProgressLessons }}

-
-
-

Average completion

-

{{ averageCompletion }}%

-
-
-
- - +
+
+
+

EchoEd Teach

+

Today in Teach

+

+ Welcome, {{ userInfo.fullname }}. Start with class needs, learner progress, work that may need review, + and curriculum you can preview before assigning. +

+ +
+ +
+
+ Classes + {{ students.length }} +

Authorized learner profiles available for assignment and roster matching.

+
+
+ Active learning + {{ inProgressLessons }} +

Learner course rows currently reported as active by the teacher summary API.

+
+
+ Average progress + {{ averageCompletion }}% +

Simple average of supported course progress rows. This is not an urgency score.

+
-
-
-
-
-
-

Course Management

- Demo-safe view -
-

- The recommended live demo stays on read-only course visibility and governance context. Off-script catalog actions are intentionally de-emphasized here. -

-
-
- - - - - - -
-
-
-
-

Course

-

{{ course.title }}

-
- - {{ flagshipCourse?.id === course.id ? 'Flagship course' : 'Visible in teacher view' }} - -
-
- -
-
-
- View More -
- -
-
-

Publish readiness

-

Read-only readiness checks for the visible educator course set.

-
- - - - - -
-
-
-
-

{{ readiness.course_title }}

-

- {{ readiness.blocking_issue_count }} blocking issues - • {{ readiness.warning_count }} warnings -

-
- - {{ readinessStateLabel(readiness.is_ready) }} - -
- -
-

Blocking issues

-
    -
  • - {{ issue.entity_title }}: {{ issue.message }} -
  • -
-
- -
-

Warnings

-
    -
  • - {{ warning.entity_title }}: {{ warning.message }} -
  • -
-
-
-
-
- -
-
-

Safe publish validation

-

Read-only learner-safety checks for the visible educator course set.

-
- - - - - -
-
-
-
-

{{ validation.course_title }}

-

- {{ validation.blocking_issue_count }} blocking issues - • {{ validation.warning_count }} warnings -

-
- - {{ safePublishStateLabel(validation.is_safe) }} - -
- -
-

Blocking issues

-
    -
  • - {{ issue.entity_title }}: {{ issue.message }} -
  • -
-
- -
-

Warnings

-
    -
  • - {{ warning.entity_title }}: {{ warning.message }} -
  • -
-
-
-
-
- -
-
-

Competency evidence integrity

-

Read-only mastery explainability checks for the visible educator course set.

-
- - - - - -
-
-
-
-

{{ integrity.course_title }}

-

- {{ integrity.blocking_issue_count }} blocking issues - • {{ integrity.warning_count }} warnings -

-
-
- - {{ competencyIntegrityStateLabel(integrity.is_valid) }} - - - {{ competencyExplainabilityLabel(integrity.is_explainable) }} - -
-
- -
-

Affected competencies

-

{{ integrity.affected_competency_identifiers.join(', ') }}

-
- -
-

Affected assessments

-
    -
  • - {{ assessment.assessment_title }} - - : {{ assessment.competency_identifiers.join(', ') }} - -
  • -
-
- -
-

Blocking issues

-
    -
  • - {{ issue.entity_title }}: {{ issue.message }} -
  • -
-
- -
-

Warnings

-
    -
  • - {{ warning.entity_title }}: {{ warning.message }} -
  • -
-
-
-
-
- -
-
-

Runtime intervention recommendations

-

Course-level recommendation detail for each learner, including evidence basis, confidence, and caution flags.

-
- - - - - -
-
-
-
-

{{ group.courseTitle }}

-

- {{ group.recommendations.length }} learner recommendations -

-
- - Flagship course - -
- -
-
-
-
-

{{ recommendation.student_name }}

-

{{ recommendation.summary }}

-
-
- - {{ formatRuntimeLabel(recommendation.recommendation_state) }} - - - {{ formatRuntimeLabel(recommendation.educator_attention_level) }} attention - -
-
- -
-
-

Confidence

-

{{ formatRuntimeLabel(recommendation.confidence_level) }}

-
-
-

Learner-safe message

-

{{ recommendation.learner_safe_message }}

-
-
- -
-

Evidence basis

-
    -
  • - {{ formatRuntimeLabel(basis.source) }}: {{ basis.detail }} - - ({{ basis.assessment_title }}) - - - - {{ basis.competency_identifiers.join(', ') }} - -
  • -
-
- -
-

Caution flags

-

{{ formatRuntimeLabels(recommendation.caution_flags) }}

-
-
-
-
-
-
-
- - -
- -
-
-
-
-

Student Progress

-

A classroom snapshot of who has started, who is moving steadily, and who may need live support during the pilot walkthrough.

+
+
+
+
+

Needs attention today

+

Learner support and review signals

+
- - -
-
-
-
-

{{ row.student_name }}

-

{{ row.course_title }}

-
- {{ row.status }} -
-
-
-

Progress

-

{{ row.progress }}%

-
-
-
-
-
-
-

Last active

-

{{ row.last_active ? (row.last_active | date: 'MMM d, y') : 'Not started' }}

-
-
-
-
-
-
-
-
-
-
-
-

Runtime Support

-

Flagship spotlight for fast live-demo narration about who needs support, who is ready for extension, and who is moving steadily.

-
- - Flagship - -
+
+
+
+

{{ row.student_name }}

+

{{ row.course_title }}

-
- - - - - -
-
-
-
-

{{ row.student_name }}

-

{{ row.course_title }}

-
- - {{ runtimeSupportStateLabel[row.support_state] || runtimeSupportStateLabel['unknown'] }} - +
+ {{ row.progress }}% + {{ row.status === 'active' ? 'In progress' : (row.status | titlecase) }} + +
+

{{ row.last_active ? (row.last_active | date:'MMM d, y') : 'No recent activity available' }}

+
+
+
-

{{ row.support_summary }}

+ +
-
-

Context

-

{{ row.context_lesson_title }}

-

- Focus concepts: {{ row.context_key_concepts.join(', ') }} -

-
    -
  • - {{ prompt }} -
  • -
-
+
+
+
+

Curriculum shortcuts

+

Courses ready to preview

+
+ Browse all +
-

- Latest evidence: {{ row.last_evidence_at | date: 'MMM d, y' }} -

- -
-
+ + + + +
+ +
+
-
-
-
-

Upcoming Events

-

A small credibility surface for pilot walkthroughs so the dashboard feels like an active teacher workspace.

-
-
-
    -
  • Parent-Teacher Conference - March 15
  • -
  • Live project showcase - March 20
  • -
  • Course feedback survey - March 30
  • -
-
+
+

Discussions and feedback

+

Supported review surfaces

+
+

Work review: assessment attempts are auto-scored by the existing assessment API. Manual grading and return-work workflows are not exposed because no teacher feedback persistence endpoint is verified.

+

Discussions: thread and post APIs exist, but they are not scoped to classes or moderation permissions. Teach links keep discussion moderation out of the main workflow until backend scope is verified.

-
-
+ + diff --git a/frontend/src/app/pages/user-dashboard/teacher-view/teacher-view.component.scss b/frontend/src/app/pages/user-dashboard/teacher-view/teacher-view.component.scss index 797df6d..75f154d 100644 --- a/frontend/src/app/pages/user-dashboard/teacher-view/teacher-view.component.scss +++ b/frontend/src/app/pages/user-dashboard/teacher-view/teacher-view.component.scss @@ -2,130 +2,219 @@ display: block; } -.teacher-dashboard { - color: var(--echo-text, #0f172a); +.teach-home { + color: var(--ee-text, #102033); + display: grid; + gap: clamp(1rem, 2vw, 1.5rem); + padding: clamp(1rem, 3vw, 2rem); } -.teacher-dashboard__hero-badge, -.teacher-dashboard__hero-panel { - backdrop-filter: blur(16px); +.teach-home__header, +.teach-home__panel, +.teach-home__priority, +.teach-home__course { + background: var(--ee-surface, #fffdf8); + border: 1px solid var(--ee-border, #dccdb7); + border-radius: var(--ee-radius-lg, 8px); + box-shadow: var(--ee-shadow-soft, 0 12px 30px rgba(16, 32, 51, 0.08)); } -.teacher-dashboard__section-heading, -.teacher-dashboard__subsection-heading { - position: relative; +.teach-home__header { + align-items: start; + display: grid; + gap: 1rem; + grid-template-columns: minmax(0, 1fr) auto; + padding: clamp(1rem, 3vw, 1.5rem); } -.teacher-dashboard__section-heading::after { - content: ''; - display: block; - margin-top: 0.9rem; - height: 1px; - width: 100%; - background: linear-gradient(90deg, rgba(15, 23, 42, 0.12), rgba(15, 23, 42, 0)); +.teach-home__eyebrow, +.teach-home__priority span { + color: var(--ee-text-muted, #5f6f7f); + font-size: 0.76rem; + font-weight: 900; + letter-spacing: 0.12em; + margin: 0; + text-transform: uppercase; +} + +h1, +h2, +h3, +p { + letter-spacing: 0; + margin: 0; +} + +h1 { + font-size: clamp(2rem, 5vw, 3.5rem); + line-height: 1; } -.teacher-dashboard__subsection-heading h4, -.teacher-dashboard__section-heading h3 { - letter-spacing: -0.01em; +h2 { + font-size: clamp(1.2rem, 3vw, 1.55rem); } -.teacher-dashboard__link-button, -.teacher-dashboard__text-link, -.teacher-dashboard__primary-button, -.teacher-dashboard__secondary-button, -.teacher-dashboard__control { - transition: - background-color 140ms ease, - border-color 140ms ease, - color 140ms ease, - box-shadow 140ms ease, - transform 140ms ease; +h3 { + font-size: 1rem; } -.teacher-dashboard__link-button { - border-radius: 9999px; - padding: 0.45rem 0.85rem; +.teach-home__header p, +.teach-home__row p, +.teach-home__course p, +.teach-home__note, +.teach-home__limits, +.teach-home__muted { + color: var(--ee-text-soft, #40546b); + line-height: 1.6; } -.teacher-dashboard__link-button:hover, -.teacher-dashboard__link-button:focus-visible { - background: rgba(14, 116, 144, 0.08); +.teach-home__quick-nav, +.teach-home__actions, +.teach-home__course-actions { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; } -.teacher-dashboard__text-link { - font-weight: 600; +.teach-home a, +.teach-home button { + border-radius: var(--ee-radius-control-default, 8px); + font-weight: 850; + min-height: 2.75rem; +} + +.teach-home a { + align-items: center; + background: var(--ee-color-action-secondary-background, #fffdf8); + border: 1px solid var(--ee-color-action-secondary-border, #cdbda3); + color: var(--ee-action-primary, #0f766e); + display: inline-flex; + padding: 0.65rem 0.9rem; text-decoration: none; } -.teacher-dashboard__text-link:hover, -.teacher-dashboard__text-link:focus-visible { - text-decoration: underline; - text-underline-offset: 0.2rem; +.teach-home button { + background: var(--ee-surface-muted, #f4eadc); + border: 1px solid var(--ee-border, #dccdb7); + color: var(--ee-action-primary, #0f766e); + cursor: pointer; + padding: 0.65rem 0.9rem; } -.teacher-dashboard__control { - color: #0f172a; +.teach-home a:focus-visible, +.teach-home button:focus-visible { + outline: 3px solid var(--ee-focus-ring, #0f766e); + outline-offset: 3px; } -.teacher-dashboard__control:hover { - border-color: rgba(14, 116, 144, 0.32); +.teach-home__priority-grid, +.teach-home__course-grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(3, minmax(0, 1fr)); } -.teacher-dashboard__control:focus-visible, -.teacher-dashboard__link-button:focus-visible, -.teacher-dashboard__text-link:focus-visible, -.teacher-dashboard__primary-button:focus-visible, -.teacher-dashboard__secondary-button:focus-visible { - outline: none; - box-shadow: 0 0 0 3px rgba(14, 116, 144, 0.18); +.teach-home__priority, +.teach-home__course, +.teach-home__panel { + display: grid; + gap: 0.85rem; + padding: clamp(1rem, 2vw, 1.25rem); } -.teacher-dashboard__primary-button:hover, -.teacher-dashboard__secondary-button:hover { - transform: translateY(-1px); +.teach-home__priority strong { + color: var(--ee-action-primary, #0f766e); + font-size: 2rem; + line-height: 1; } -.teacher-dashboard__primary-button { - background: linear-gradient(135deg, var(--color-primary, #0e7490), #155e75); +.teach-home__layout { + display: grid; + gap: 1rem; + grid-template-columns: minmax(0, 1.55fr) minmax(18rem, 0.75fr); } -.teacher-dashboard__primary-button:hover { - filter: brightness(1.02); +.teach-home__panel-heading, +.teach-home__row { + align-items: start; + display: flex; + gap: 1rem; + justify-content: space-between; } -.teacher-dashboard__secondary-button { - border: 1px solid rgba(14, 116, 144, 0.2); - background: #fff; +.teach-home__data-list { + display: grid; + gap: 0.75rem; } -.teacher-dashboard__secondary-button:hover { - border-color: rgba(14, 116, 144, 0.32); - background: rgba(14, 116, 144, 0.04); +.teach-home__row { + background: var(--ee-surface-muted, #f8fafc); + border: 1px solid var(--ee-border, #dccdb7); + border-radius: var(--ee-radius, 6px); + padding: 0.9rem; } -.teacher-dashboard__secondary-button--accent { - border-color: rgba(245, 158, 11, 0.2); - background: linear-gradient(135deg, rgba(252, 211, 77, 0.75), rgba(254, 240, 138, 0.82)); +.teach-home__progress { + display: grid; + gap: 0.35rem; + min-width: 11rem; } -.teacher-dashboard__secondary-button--accent:hover { - border-color: rgba(217, 119, 6, 0.35); - background: linear-gradient(135deg, rgba(252, 211, 77, 0.92), rgba(254, 240, 138, 0.96)); +.teach-home__progress span:first-child { + color: var(--ee-action-primary, #0f766e); + font-weight: 900; } -@media (max-width: 1023px) { - .teacher-dashboard__section-heading::after { - margin-top: 0.75rem; +.teach-home__progress span:nth-child(2) { + color: var(--ee-text-muted, #5f6f7f); + font-size: 0.8rem; + font-weight: 800; +} + +.teach-home__track { + background: var(--ee-border, #dccdb7); + border-radius: 999px; + height: 0.55rem; + overflow: hidden; +} + +.teach-home__track div { + background: var(--ee-action-primary, #0f766e); + height: 100%; +} + +.teach-home__limits { + display: grid; + gap: 0.7rem; +} + +@media (max-width: 980px) { + .teach-home__header, + .teach-home__layout { + grid-template-columns: 1fr; + } + + .teach-home__priority-grid, + .teach-home__course-grid { + grid-template-columns: 1fr; } } -@media (max-width: 767px) { - .teacher-dashboard__hero-panel { - padding: 1rem; +@media (max-width: 520px) { + .teach-home { + padding: 0.75rem; + } + + .teach-home__panel-heading, + .teach-home__row { + display: grid; } - .teacher-dashboard__link-button { - padding-inline: 0; + .teach-home__quick-nav a, + .teach-home__actions a, + .teach-home__course-actions a, + .teach-home button { + justify-content: center; + width: 100%; } } diff --git a/frontend/src/app/pages/user-dashboard/teacher-view/teacher-view.component.spec.ts b/frontend/src/app/pages/user-dashboard/teacher-view/teacher-view.component.spec.ts index af08cf0..c182c20 100644 --- a/frontend/src/app/pages/user-dashboard/teacher-view/teacher-view.component.spec.ts +++ b/frontend/src/app/pages/user-dashboard/teacher-view/teacher-view.component.spec.ts @@ -11,44 +11,13 @@ import { ToastService } from '../../../services/toast.service'; class MockCoursesService { coursesResponse = of([]); - getCourses = jasmine.createSpy('getCourses').and.callFake(() => this.coursesResponse); - publishReadinessResponse = of({ - course_id: 'course-1', - course_title: 'Introduction to Africa', - is_ready: true, - blocking_issue_count: 0, - warning_count: 0, - blocking_issues: [], - warnings: [], - }); - getCoursePublishReadiness = jasmine.createSpy('getCoursePublishReadiness').and.callFake(() => this.publishReadinessResponse); - safePublishResponse = of({ - course_id: 'course-1', - course_title: 'Introduction to Africa', - is_safe: true, - blocking_issue_count: 0, - warning_count: 0, - blocking_issues: [], - warnings: [], - }); - getCourseSafePublishValidation = jasmine.createSpy('getCourseSafePublishValidation').and.callFake(() => this.safePublishResponse); - competencyIntegrityResponse = of({ - course_id: 'course-1', - course_title: 'Introduction to Africa', - is_valid: true, - is_explainable: true, - blocking_issue_count: 0, - warning_count: 0, - blocking_issues: [], - warnings: [], - affected_assessments: [], - affected_competency_identifiers: [], - }); - getCourseCompetencyEvidenceIntegrity = jasmine.createSpy('getCourseCompetencyEvidenceIntegrity').and.callFake(() => this.competencyIntegrityResponse); - runtimeInterventionResponse = of([]); - getCourseRuntimeInterventionRecommendations = jasmine.createSpy('getCourseRuntimeInterventionRecommendations').and.callFake(() => this.runtimeInterventionResponse); governanceSummaryResponse = of(buildGovernanceSummary()); + getCourses = jasmine.createSpy('getCourses').and.callFake(() => this.coursesResponse); getCourseGovernanceSummary = jasmine.createSpy('getCourseGovernanceSummary').and.callFake(() => this.governanceSummaryResponse); + getCoursePublishReadiness = jasmine.createSpy('getCoursePublishReadiness').and.returnValue(of({})); + getCourseSafePublishValidation = jasmine.createSpy('getCourseSafePublishValidation').and.returnValue(of({})); + getCourseCompetencyEvidenceIntegrity = jasmine.createSpy('getCourseCompetencyEvidenceIntegrity').and.returnValue(of({})); + getCourseRuntimeInterventionRecommendations = jasmine.createSpy('getCourseRuntimeInterventionRecommendations').and.returnValue(of([])); deleteCourse = jasmine.createSpy('deleteCourse').and.returnValue(of({})); assignCourseToStudent = jasmine.createSpy('assignCourseToStudent').and.returnValue(of({})); } @@ -71,53 +40,14 @@ class MockToastService { function buildGovernanceSummary(overrides: Partial = {}) { return { - course_id: overrides['course_id'] || 'course-1', - course_title: overrides['course_title'] || 'Introduction to Africa', - publish_readiness: { - course_id: 'course-1', - course_title: 'Introduction to Africa', - is_ready: true, - blocking_issue_count: 0, - warning_count: 0, - blocking_issues: [], - warnings: [], - ...(overrides['publish_readiness'] || {}), - }, - safe_publish_validation: { - course_id: 'course-1', - course_title: 'Introduction to Africa', - is_safe: true, - blocking_issue_count: 0, - warning_count: 0, - blocking_issues: [], - warnings: [], - ...(overrides['safe_publish_validation'] || {}), - }, - lineage_safety_visibility: { - course_id: 'course-1', - course_title: 'Introduction to Africa', - is_coherent: true, - is_safe: true, - blocking_issue_count: 0, - warning_count: 0, - blocking_issues: [], - warnings: [], - ...(overrides['lineage_safety_visibility'] || {}), - }, - competency_evidence_integrity: { - course_id: 'course-1', - course_title: 'Introduction to Africa', - is_valid: true, - is_explainable: true, - blocking_issue_count: 0, - warning_count: 0, - blocking_issues: [], - warnings: [], - affected_assessments: [], - affected_competency_identifiers: [], - ...(overrides['competency_evidence_integrity'] || {}), - }, - runtime_intervention_recommendations: overrides['runtime_intervention_recommendations'] || [], + course_id: 'course-1', + course_title: 'Introduction to Africa', + publish_readiness: { course_id: 'course-1', course_title: 'Introduction to Africa', is_ready: true, blocking_issue_count: 0, warning_count: 0, blocking_issues: [], warnings: [] }, + safe_publish_validation: { course_id: 'course-1', course_title: 'Introduction to Africa', is_safe: true, blocking_issue_count: 0, warning_count: 0, blocking_issues: [], warnings: [] }, + lineage_safety_visibility: { course_id: 'course-1', course_title: 'Introduction to Africa', is_coherent: true, is_safe: true, blocking_issue_count: 0, warning_count: 0, blocking_issues: [], warnings: [] }, + competency_evidence_integrity: { course_id: 'course-1', course_title: 'Introduction to Africa', is_valid: true, is_explainable: true, blocking_issue_count: 0, warning_count: 0, blocking_issues: [], warnings: [], affected_assessments: [], affected_competency_identifiers: [] }, + runtime_intervention_recommendations: [], + ...overrides, }; } @@ -127,8 +57,9 @@ describe('TeacherViewComponent', () => { let coursesService: MockCoursesService; let analyticsService: MockAnalyticsService; let usersService: MockUsersService; + let toastService: MockToastService; - async function createComponent() { + async function createComponent(role = 'teacher') { await TestBed.configureTestingModule({ imports: [RouterTestingModule, HttpClientTestingModule, TeacherViewComponent], providers: [ @@ -141,514 +72,170 @@ describe('TeacherViewComponent', () => { fixture = TestBed.createComponent(TeacherViewComponent); component = fixture.componentInstance; - component.userInfo = { fullname: 'Teacher Test', role: 'teacher' } as any; + component.userInfo = { fullname: 'Teacher Test', role } as any; coursesService = TestBed.inject(CoursesService) as unknown as MockCoursesService; analyticsService = TestBed.inject(AnalyticsService) as unknown as MockAnalyticsService; usersService = TestBed.inject(UsersService) as unknown as MockUsersService; + toastService = TestBed.inject(ToastService) as unknown as MockToastService; } beforeEach(async () => { await createComponent(); }); - it('uses EchoLoadingState while teacher data is loading', () => { - const pendingCourses = new Subject(); - const pendingStudents = new Subject(); - const pendingSummary = new Subject(); - const pendingRuntimeSupport = new Subject(); - const pendingGovernanceSummary = new Subject(); - coursesService.coursesResponse = pendingCourses.asObservable(); - coursesService.governanceSummaryResponse = pendingGovernanceSummary.asObservable(); - usersService.studentsResponse = pendingStudents.asObservable(); - analyticsService.summaryResponse = pendingSummary.asObservable(); - analyticsService.runtimeSupportResponse = pendingRuntimeSupport.asObservable(); - - fixture.detectChanges(); - - const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.querySelector('[data-echo-loading="section"]')).not.toBeNull(); - expect(compiled.textContent).toContain('Loading course management'); - expect(compiled.textContent).toContain('Loading student progress'); - expect(compiled.textContent).toContain('Loading runtime support'); - expect(compiled.textContent).toContain('Loading publish readiness'); - expect(compiled.textContent).toContain('Loading safe publish validation'); - expect(compiled.textContent).toContain('Loading competency evidence integrity'); - expect(compiled.textContent).toContain('Loading runtime intervention recommendations'); - }); - - it('uses EchoStatePanel for teacher empty states', () => { - coursesService.coursesResponse = of([]); - fixture.detectChanges(); - - const compiled = fixture.nativeElement as HTMLElement; - const emptyStates = compiled.querySelectorAll('[data-echo-state="empty"]'); - expect(emptyStates.length).toBeGreaterThan(0); - expect(compiled.textContent).toContain('Course management will appear here'); - expect(compiled.textContent).toContain('Student progress will appear here'); - expect(compiled.textContent).toContain('Flagship runtime support is not available yet'); - expect(compiled.textContent).toContain('Publish readiness will appear here'); - expect(compiled.textContent).toContain('Safe publish validation will appear here'); - expect(compiled.textContent).toContain('Competency evidence integrity will appear here'); - expect(compiled.textContent).toContain('Runtime intervention recommendations will appear here'); - }); - - it('uses EchoStatePanel for teacher error states and retries data loads', () => { - coursesService.coursesResponse = throwError(() => new Error('courses failed')); - usersService.studentsResponse = throwError(() => new Error('students failed')); - analyticsService.summaryResponse = throwError(() => new Error('summary failed')); - analyticsService.runtimeSupportResponse = throwError(() => new Error('runtime support failed')); - - fixture.detectChanges(); - expect(coursesService.getCourses).toHaveBeenCalledTimes(1); - expect(usersService.getStudents).toHaveBeenCalledTimes(1); - expect(analyticsService.getTeacherSummary).toHaveBeenCalledTimes(1); - expect(analyticsService.getEducatorRuntimeSupport).not.toHaveBeenCalled(); - expect(coursesService.getCourseGovernanceSummary).not.toHaveBeenCalled(); - - coursesService.coursesResponse = of([{ id: 'course-1', title: 'Introduction to Africa', standards_metadata: { pathway_key: 'introduction-to-africa' } }]); - coursesService.governanceSummaryResponse = of(buildGovernanceSummary()); - usersService.studentsResponse = of([{ id: 'student-1', firstname: 'Ada', lastname: 'Lovelace' }]); - analyticsService.summaryResponse = of([{ student_name: 'Ada Lovelace', course_title: 'Algebra I', progress: 50, status: 'active', last_active: '2026-05-01T00:00:00.000Z' }]); - analyticsService.runtimeSupportResponse = of([]); + function text(): string { + return (fixture.nativeElement as HTMLElement).textContent || ''; + } - const buttons = Array.from(fixture.nativeElement.querySelectorAll('button')) as HTMLButtonElement[]; - buttons.filter((button) => button.textContent?.trim() === 'Retry').forEach((button) => button.click()); + it('renders the canonical Teach overview for teacher state', () => { fixture.detectChanges(); - - expect(coursesService.getCourses).toHaveBeenCalledTimes(3); - expect(usersService.getStudents).toHaveBeenCalledTimes(2); - expect(analyticsService.getTeacherSummary).toHaveBeenCalledTimes(2); - expect(analyticsService.getEducatorRuntimeSupport).toHaveBeenCalledTimes(2); - expect(coursesService.getCourseGovernanceSummary).toHaveBeenCalledWith('course-1'); - expect(coursesService.getCoursePublishReadiness).not.toHaveBeenCalled(); - expect(coursesService.getCourseSafePublishValidation).not.toHaveBeenCalled(); - expect(coursesService.getCourseCompetencyEvidenceIntegrity).not.toHaveBeenCalled(); - expect(coursesService.getCourseRuntimeInterventionRecommendations).not.toHaveBeenCalled(); + expect(text()).toContain('EchoEd Teach'); + expect(text()).toContain('Today in Teach'); + expect(text()).toContain('My classes'); + expect(text()).toContain('Browse curriculum'); + expect(text()).toContain('Assignments'); }); - it('demotes off-script teacher actions while keeping the dashboard usable', () => { - coursesService.coursesResponse = of([{ id: 'course-1', title: 'Introduction to Africa', standards_metadata: { pathway_key: 'introduction-to-africa' } }]); - usersService.studentsResponse = of([{ id: 'student-1', firstname: 'Ada', lastname: 'Lovelace' }]); - analyticsService.summaryResponse = of([{ student_name: 'Ada Lovelace', course_title: 'Algebra I', progress: 50, status: 'active', last_active: '2026-05-01T00:00:00.000Z' }]); - analyticsService.runtimeSupportResponse = of([]); - + it('renders the same Teach overview for instructor-compatible state', async () => { + TestBed.resetTestingModule(); + await createComponent('instructor'); fixture.detectChanges(); - const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.textContent).toContain('Demo-safe view'); - expect(compiled.textContent).toContain('Advanced actions'); - expect(compiled.textContent).toContain('Off-script'); - expect(compiled.textContent).toContain('Assign Course'); - expect(compiled.textContent).toContain('Start Live Lesson'); - expect(compiled.textContent).toContain('View Reports'); - expect(compiled.textContent).not.toContain('Add Course'); - expect(compiled.textContent).not.toContain('Delete'); - expect(compiled.textContent).not.toContain('Publish Course'); + expect(text()).toContain('Today in Teach'); + expect(component.userInfo.role).toBe('instructor'); }); - it('gives runtime intervention and runtime support distinct framing', () => { - coursesService.coursesResponse = of([{ id: 'course-1', title: 'Introduction to Africa', standards_metadata: { pathway_key: 'introduction-to-africa' } }]); - usersService.studentsResponse = of([{ id: 'student-1', firstname: 'Ada', lastname: 'Lovelace' }]); - analyticsService.summaryResponse = of([]); - analyticsService.runtimeSupportResponse = of([]); - coursesService.governanceSummaryResponse = of(buildGovernanceSummary()); - + it('uses shared loading states while teacher data is loading', () => { + coursesService.coursesResponse = new Subject().asObservable(); + usersService.studentsResponse = new Subject().asObservable(); + analyticsService.summaryResponse = new Subject().asObservable(); fixture.detectChanges(); - - const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.textContent).toContain('Course-level recommendation detail for each learner, including evidence basis, confidence, and caution flags.'); - expect(compiled.textContent).toContain('Flagship spotlight for fast live-demo narration about who needs support, who is ready for extension, and who is moving steadily.'); + expect(text()).toContain('Loading learner progress'); + expect(text()).toContain('Loading curriculum'); }); - it('renders responsive fallback markup for educator tables', () => { - coursesService.coursesResponse = of([{ id: 'course-1', title: 'Introduction to Africa', standards_metadata: { pathway_key: 'introduction-to-africa' } }]); - usersService.studentsResponse = of([{ id: 'student-1', firstname: 'Ada', lastname: 'Lovelace' }]); - analyticsService.summaryResponse = of([{ student_name: 'Ada Lovelace', course_title: 'Algebra I', progress: 50, status: 'active', last_active: '2026-05-01T00:00:00.000Z' }]); - analyticsService.runtimeSupportResponse = of([]); - + it('renders no classes and no assignments entry state without inventing counts', () => { fixture.detectChanges(); - const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.querySelector('[data-educator-responsive="teacher-courses"]')).not.toBeNull(); - expect(compiled.querySelector('[data-educator-responsive="teacher-summary"]')).not.toBeNull(); + expect(text()).toContain('No progress yet'); + expect(text()).toContain('No curriculum is available'); + expect(text()).toContain('Create or inspect assignments'); }); - it('renders publish readiness ready state in the teacher staff surface', () => { - coursesService.coursesResponse = of([{ id: 'course-1', title: 'Introduction to Africa', standards_metadata: { pathway_key: 'introduction-to-africa' } }]); - coursesService.governanceSummaryResponse = of(buildGovernanceSummary({ - publish_readiness: { - warning_count: 1, - warnings: [{ entity_type: 'unit', entity_id: 'unit-1', entity_title: 'Unit One', code: 'empty_unit', message: 'Unit does not contain any lessons yet.' }], - }, - })); - usersService.studentsResponse = of([{ id: 'student-1', firstname: 'Ada', lastname: 'Lovelace' }]); - analyticsService.summaryResponse = of([]); - analyticsService.runtimeSupportResponse = of([]); - + it('renders needs-review limitations from supported data only', () => { fixture.detectChanges(); - - expect(coursesService.getCourseGovernanceSummary).toHaveBeenCalledWith('course-1'); - expect(coursesService.getCoursePublishReadiness).not.toHaveBeenCalled(); - const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.textContent).toContain('Publish readiness'); - expect(compiled.textContent).toContain('Introduction to Africa'); - expect(compiled.textContent).toContain('Ready'); - expect(compiled.textContent).toContain('1 warning'); + expect(text()).toContain('Feedback, manual scoring, and discussion moderation are shown only where current APIs support them.'); + expect(text()).toContain('thread and post APIs exist, but they are not scoped to classes or moderation permissions.'); }); - it('renders blocked publish readiness issues with retry and no mutation actions', () => { - coursesService.coursesResponse = of([{ id: 'course-1', title: 'Introduction to Africa', standards_metadata: { pathway_key: 'introduction-to-africa' } }]); - coursesService.governanceSummaryResponse = throwError(() => new Error('readiness failed')); - usersService.studentsResponse = of([{ id: 'student-1', firstname: 'Ada', lastname: 'Lovelace' }]); - analyticsService.summaryResponse = of([]); - analyticsService.runtimeSupportResponse = of([]); - - fixture.detectChanges(); - let compiled = fixture.nativeElement as HTMLElement; - expect(compiled.textContent).toContain('We could not load course publish readiness'); - - coursesService.governanceSummaryResponse = of(buildGovernanceSummary({ - publish_readiness: { - is_ready: false, - blocking_issue_count: 2, - warning_count: 0, - blocking_issues: [ - { entity_type: 'lesson', entity_id: 'lesson-1', entity_title: 'Draft Lesson', code: 'review_status_not_approved', message: 'Lesson must be approved before it is publish-ready for learners.' }, - { entity_type: 'lesson', entity_id: 'lesson-1', entity_title: 'Draft Lesson', code: 'missing_readiness_field', message: 'Lesson is missing required publish-readiness field: sources.' }, - ], - warnings: [], - }, - })); - - const retryButton = Array.from(compiled.querySelectorAll('button')).find( - (button) => button.textContent?.trim() === 'Retry', - ) as HTMLButtonElement; - retryButton.click(); + it('renders API failure states and retry actions', () => { + coursesService.coursesResponse = throwError(() => new Error('courses failed')); + analyticsService.summaryResponse = throwError(() => new Error('summary failed')); fixture.detectChanges(); - - compiled = fixture.nativeElement as HTMLElement; - expect(coursesService.getCourseGovernanceSummary).toHaveBeenCalledTimes(2); - expect(coursesService.getCoursePublishReadiness).not.toHaveBeenCalled(); - expect(compiled.textContent).toContain('Not ready'); - expect(compiled.textContent).toContain('Draft Lesson'); - expect(coursesService.assignCourseToStudent).not.toHaveBeenCalled(); - expect(coursesService.deleteCourse).not.toHaveBeenCalled(); + expect(text()).toContain('We could not load learner progress'); + expect(text()).toContain('We could not load courses'); + expect((fixture.nativeElement as HTMLElement).querySelectorAll('button').length).toBeGreaterThan(0); }); - it('renders safe-publish safe state in the teacher staff surface', () => { - coursesService.coursesResponse = of([{ id: 'course-1', title: 'Introduction to Africa', standards_metadata: { pathway_key: 'introduction-to-africa' } }]); - coursesService.governanceSummaryResponse = of(buildGovernanceSummary({ - safe_publish_validation: { - warning_count: 1, - warnings: [ - { entity_type: 'course', entity_id: 'course-1', entity_title: 'Introduction to Africa', code: 'draft_has_published_at', message: 'Draft revisions should not already have a published timestamp.' }, - ], - }, - })); - usersService.studentsResponse = of([{ id: 'student-1', firstname: 'Ada', lastname: 'Lovelace' }]); - analyticsService.summaryResponse = of([]); - analyticsService.runtimeSupportResponse = of([]); - + it('keeps unauthorized/admin and Studio links out of teacher navigation content', () => { fixture.detectChanges(); - - expect(coursesService.getCourseGovernanceSummary).toHaveBeenCalledWith('course-1'); - expect(coursesService.getCourseSafePublishValidation).not.toHaveBeenCalled(); - const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.textContent).toContain('Safe publish validation'); - expect(compiled.textContent).toContain('Safe'); - expect(compiled.textContent).toContain('1 warning'); - expect(compiled.querySelector('[data-safe-publish-row]')).not.toBeNull(); + expect(text()).not.toContain('Admin Overview'); + expect(text()).not.toContain('Content Studio'); + expect(text()).not.toContain('Product Studio'); }); - it('renders competency integrity valid and explainable state in the teacher staff surface', () => { - coursesService.coursesResponse = of([{ id: 'course-1', title: 'Introduction to Africa', standards_metadata: { pathway_key: 'introduction-to-africa' } }]); - coursesService.governanceSummaryResponse = of(buildGovernanceSummary({ - competency_evidence_integrity: { - warning_count: 1, - warnings: [ - { entity_type: 'assessment', entity_id: 'assessment-2', entity_title: 'Practice Quiz', code: 'unaligned_assessment_mastery_evidence', message: 'Assessment evidence is not aligned to a competency.' }, - ], - }, - })); - usersService.studentsResponse = of([{ id: 'student-1', firstname: 'Ada', lastname: 'Lovelace' }]); - analyticsService.summaryResponse = of([]); - analyticsService.runtimeSupportResponse = of([]); - + it('renders learner progress rows with consistent state language', () => { + analyticsService.summaryResponse = of([{ student_name: 'Ada Lovelace', course_title: 'Introduction to Africa', progress: 45, status: 'active', last_active: '2026-05-01T00:00:00.000Z' }]); fixture.detectChanges(); - - expect(coursesService.getCourseGovernanceSummary).toHaveBeenCalledWith('course-1'); - expect(coursesService.getCourseCompetencyEvidenceIntegrity).not.toHaveBeenCalled(); - const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.textContent).toContain('Competency evidence integrity'); - expect(compiled.textContent).toContain('Valid'); - expect(compiled.textContent).toContain('Explainable'); - expect(compiled.textContent).toContain('1 warning'); - expect(compiled.querySelector('[data-competency-integrity-row]')).not.toBeNull(); + expect(text()).toContain('Ada Lovelace'); + expect(text()).toContain('Introduction to Africa'); + expect(text()).toContain('45%'); + expect(text()).toContain('In progress'); }); - it('renders competency integrity affected assessments, competencies, and issues with retry and no mutation actions', () => { - coursesService.coursesResponse = of([{ id: 'course-1', title: 'Introduction to Africa', standards_metadata: { pathway_key: 'introduction-to-africa' } }]); - coursesService.governanceSummaryResponse = throwError(() => new Error('integrity failed')); - usersService.studentsResponse = of([{ id: 'student-1', firstname: 'Ada', lastname: 'Lovelace' }]); - analyticsService.summaryResponse = of([]); - analyticsService.runtimeSupportResponse = of([]); - - fixture.detectChanges(); - let compiled = fixture.nativeElement as HTMLElement; - expect(compiled.textContent).toContain('We could not load competency evidence integrity'); - - coursesService.governanceSummaryResponse = of(buildGovernanceSummary({ - competency_evidence_integrity: { - is_valid: false, - is_explainable: false, - blocking_issue_count: 1, - warning_count: 1, - blocking_issues: [ - { entity_type: 'assessment', entity_id: 'assessment-1', entity_title: 'Final Assessment', code: 'missing_attempt_event_for_mastery_evidence', message: 'Attempt evidence is missing an authoritative event.' }, - ], - warnings: [ - { entity_type: 'assessment', entity_id: 'assessment-2', entity_title: 'Practice Quiz', code: 'unaligned_assessment_mastery_evidence', message: 'Assessment evidence is not aligned to a competency.' }, - ], - affected_assessments: [ - { assessment_id: 'assessment-1', assessment_title: 'Final Assessment', competency_identifiers: ['evidence', 'analysis'] }, - { assessment_id: 'assessment-2', assessment_title: 'Practice Quiz', competency_identifiers: [] }, - ], - affected_competency_identifiers: ['evidence', 'analysis'], - }, - })); - - const retryButtons = Array.from(compiled.querySelectorAll('button')).filter( - (button) => button.textContent?.trim() === 'Retry', - ) as HTMLButtonElement[]; - retryButtons[0]?.click(); + it('renders not-started language for missing activity dates', () => { + analyticsService.summaryResponse = of([{ student_name: 'Biko Student', course_title: 'History', progress: 0, status: 'pending', last_active: null }]); fixture.detectChanges(); - - compiled = fixture.nativeElement as HTMLElement; - expect(coursesService.getCourseGovernanceSummary).toHaveBeenCalledTimes(2); - expect(coursesService.getCourseCompetencyEvidenceIntegrity).not.toHaveBeenCalled(); - expect(compiled.textContent).toContain('Not valid'); - expect(compiled.textContent).toContain('Not explainable'); - expect(compiled.textContent).toContain('Affected competencies'); - expect(compiled.textContent).toContain('evidence, analysis'); - expect(compiled.textContent).toContain('Final Assessment'); - expect(compiled.textContent).toContain('Practice Quiz'); - expect(compiled.textContent).toContain('Attempt evidence is missing an authoritative event.'); - expect(coursesService.assignCourseToStudent).not.toHaveBeenCalled(); - expect(coursesService.deleteCourse).not.toHaveBeenCalled(); + expect(text()).toContain('No recent activity available'); + expect(text()).toContain('Pending'); }); - it('renders runtime intervention recommendation states, evidence basis, and caution flags in the teacher staff surface', () => { - coursesService.coursesResponse = of([{ id: 'course-1', title: 'Introduction to Africa', standards_metadata: { pathway_key: 'introduction-to-africa' } }]); - coursesService.governanceSummaryResponse = of(buildGovernanceSummary({ - runtime_intervention_recommendations: [ - { - student_id: 'student-1', - student_name: 'Ada Lovelace', - student_course_id: 'student-course-1', - course_id: 'course-1', - course_title: 'Introduction to Africa', - recommendation_state: 'reteach', - educator_attention_level: 'high', - summary: 'Recent evidence suggests a brief reteach cycle may help.', - evidence_basis: [ - { - source: 'attempt_events', - detail: 'Recent attempts show repeated weak evidence.', - assessment_id: 'assessment-1', - assessment_title: 'Exit Check', - competency_identifiers: ['analysis'], - }, - ], - confidence_level: 'high', - caution_flags: ['incomplete_evidence'], - learner_safe_message: 'Rebuild understanding with calm support and one step at a time.', - }, - ], - })); - usersService.studentsResponse = of([{ id: 'student-1', firstname: 'Ada', lastname: 'Lovelace' }]); - analyticsService.summaryResponse = of([]); - analyticsService.runtimeSupportResponse = of([]); - + it('calculates active learning and average completion from teacher summary rows', () => { + analyticsService.summaryResponse = of([ + { student_name: 'One', course_title: 'Course', progress: 50, status: 'active', last_active: null }, + { student_name: 'Two', course_title: 'Course', progress: 100, status: 'completed', last_active: null }, + ]); fixture.detectChanges(); - - expect(coursesService.getCourseGovernanceSummary).toHaveBeenCalledWith('course-1'); - expect(coursesService.getCourseRuntimeInterventionRecommendations).not.toHaveBeenCalled(); - const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.textContent).toContain('Runtime intervention recommendations'); - expect(compiled.textContent).toContain('Ada Lovelace'); - expect(compiled.textContent).toContain('Reteach'); - expect(compiled.textContent).toContain('High attention'); - expect(compiled.textContent).toContain('Evidence basis'); - expect(compiled.textContent).toContain('Attempt Events'); - expect(compiled.textContent).toContain('Exit Check'); - expect(compiled.textContent).toContain('Caution flags'); - expect(compiled.textContent).toContain('Incomplete Evidence'); - expect(compiled.querySelector('[data-runtime-intervention-row]')).not.toBeNull(); - const runtimeButtons = compiled.querySelectorAll('[data-runtime-intervention-row] button'); - expect(runtimeButtons.length).toBe(0); + expect(text()).toContain('Active learning1'); + expect(text()).toContain('Average progress75%'); }); - it('renders runtime intervention error state and retries read-only loading in the teacher staff surface', () => { - coursesService.coursesResponse = of([{ id: 'course-1', title: 'Introduction to Africa', standards_metadata: { pathway_key: 'introduction-to-africa' } }]); - coursesService.governanceSummaryResponse = throwError(() => new Error('runtime intervention failed')); - usersService.studentsResponse = of([{ id: 'student-1', firstname: 'Ada', lastname: 'Lovelace' }]); - analyticsService.summaryResponse = of([]); - analyticsService.runtimeSupportResponse = of([]); - + it('renders curriculum cards with preview and assign actions', () => { + coursesService.coursesResponse = of([{ id: 'course-1', title: 'Introduction to Africa', description: 'Course description', standards_metadata: { pathway_key: 'introduction-to-africa' } }]); fixture.detectChanges(); - let compiled = fixture.nativeElement as HTMLElement; - expect(compiled.textContent).toContain('We could not load runtime intervention recommendations'); - - coursesService.governanceSummaryResponse = of(buildGovernanceSummary()); - const retryButtons = Array.from(compiled.querySelectorAll('button')).filter( - (button) => button.textContent?.trim() === 'Retry', - ) as HTMLButtonElement[]; - retryButtons[0]?.click(); - fixture.detectChanges(); - - compiled = fixture.nativeElement as HTMLElement; - expect(coursesService.getCourseGovernanceSummary).toHaveBeenCalledTimes(2); - expect(coursesService.getCourseRuntimeInterventionRecommendations).not.toHaveBeenCalled(); - expect(coursesService.assignCourseToStudent).not.toHaveBeenCalled(); - expect(coursesService.deleteCourse).not.toHaveBeenCalled(); - expect(compiled.textContent).toContain('Runtime intervention recommendations will appear here'); + expect(text()).toContain('Introduction to Africa'); + expect(text()).toContain('Course description'); + expect(text()).toContain('Preview course'); + expect(text()).toContain('Assign learning'); }); - it('renders blocked safe-publish issues with retry and no mutation actions', () => { + it('does not call student progress creation APIs from the overview', () => { coursesService.coursesResponse = of([{ id: 'course-1', title: 'Introduction to Africa', standards_metadata: { pathway_key: 'introduction-to-africa' } }]); - coursesService.governanceSummaryResponse = throwError(() => new Error('safe publish failed')); - usersService.studentsResponse = of([{ id: 'student-1', firstname: 'Ada', lastname: 'Lovelace' }]); - analyticsService.summaryResponse = of([]); - analyticsService.runtimeSupportResponse = of([]); - - fixture.detectChanges(); - let compiled = fixture.nativeElement as HTMLElement; - expect(compiled.textContent).toContain('We could not load course safe-publish validation'); - - coursesService.governanceSummaryResponse = of(buildGovernanceSummary({ - safe_publish_validation: { - is_safe: false, - blocking_issue_count: 1, - warning_count: 1, - blocking_issues: [ - { entity_type: 'course', entity_id: 'course-1', entity_title: 'Introduction to Africa', code: 'blocked_revision_status', message: 'Deprecated or archived content is not safe to republish for learners.' }, - ], - warnings: [ - { entity_type: 'course', entity_id: 'course-1', entity_title: 'Introduction to Africa', code: 'draft_has_published_at', message: 'Draft revisions should not already have a published timestamp.' }, - ], - }, - })); - - const retryButtons = Array.from(compiled.querySelectorAll('button')).filter( - (button) => button.textContent?.trim() === 'Retry', - ) as HTMLButtonElement[]; - retryButtons[0]?.click(); fixture.detectChanges(); - - compiled = fixture.nativeElement as HTMLElement; - expect(coursesService.getCourseGovernanceSummary).toHaveBeenCalledTimes(2); - expect(coursesService.getCourseSafePublishValidation).not.toHaveBeenCalled(); - expect(compiled.textContent).toContain('Not safe'); - expect(compiled.textContent).toContain('Deprecated or archived content is not safe to republish for learners.'); + expect(coursesService.getCourses).toHaveBeenCalled(); expect(coursesService.assignCourseToStudent).not.toHaveBeenCalled(); expect(coursesService.deleteCourse).not.toHaveBeenCalled(); }); - it('loads runtime support for the flagship course and renders remediation context', () => { + it('still loads flagship runtime support for supported educator guidance data', () => { coursesService.coursesResponse = of([{ id: 'course-1', title: 'Introduction to Africa', standards_metadata: { pathway_key: 'introduction-to-africa' } }]); - usersService.studentsResponse = of([{ id: 'student-1', firstname: 'Ada', lastname: 'Lovelace' }]); - analyticsService.summaryResponse = of([{ student_name: 'Ada Lovelace', course_title: 'Introduction to Africa', progress: 50, status: 'active', last_active: '2026-05-01T00:00:00.000Z' }]); - analyticsService.runtimeSupportResponse = of([{ - student_id: 'student-1', - student_name: 'Ada Lovelace', - student_course_id: 'student-course-1', - course_id: 'course-1', - course_title: 'Introduction to Africa', - support_state: 'remediation', - support_summary: 'Recent evidence suggests the learner may benefit from brief review in Introduction to Africa.', - evidence_source: 'recent_assessment_retry', - recommended_action: 'review-and-continue', - last_evidence_at: '2026-05-01T00:00:00.000Z', - context_lesson_id: 'lesson-1', - context_lesson_title: 'Introduction to Africa', - context_key_concepts: ['continent', 'diversity'], - context_prompts: ['Revisit one idea from Introduction to Africa.'], - educator_intervention_hint: 'Offer a brief guided discussion.', - }]); - fixture.detectChanges(); - expect(analyticsService.getEducatorRuntimeSupport).toHaveBeenCalledWith('course-1'); - const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.textContent).toContain('Runtime Support'); - expect(compiled.textContent).toContain('Needs review support'); - expect(compiled.textContent).toContain('Ada Lovelace'); - expect(compiled.textContent).toContain('Introduction to Africa'); - expect(compiled.textContent).toContain('Revisit one idea from Introduction to Africa.'); - expect(compiled.textContent).not.toContain('review-and-continue'); }); - it('renders enrichment context with educator-safe summary', () => { + it('handles course governance summary failures without exposing governance UI', () => { coursesService.coursesResponse = of([{ id: 'course-1', title: 'Introduction to Africa', standards_metadata: { pathway_key: 'introduction-to-africa' } }]); - usersService.studentsResponse = of([{ id: 'student-1', firstname: 'Biko', lastname: 'Extension' }]); - analyticsService.summaryResponse = of([{ student_name: 'Biko Extension', course_title: 'Introduction to Africa', progress: 95, status: 'active', last_active: '2026-05-02T00:00:00.000Z' }]); - analyticsService.runtimeSupportResponse = of([{ - student_id: 'student-1', - student_name: 'Biko Extension', - student_course_id: 'student-course-1', - course_id: 'course-1', - course_title: 'Introduction to Africa', - support_state: 'enrichment', - support_summary: 'Recent mastery evidence is strong enough to support optional enrichment.', - evidence_source: 'strong_mastery_signal', - recommended_action: 'continue-with-enrichment', - last_evidence_at: '2026-05-02T00:00:00.000Z', - context_lesson_id: 'lesson-1', - context_lesson_title: 'Introduction to Africa', - context_key_concepts: ['curiosity'], - context_prompts: ['Look a little deeper at Introduction to Africa.'], - educator_intervention_hint: null, - }]); - + coursesService.governanceSummaryResponse = throwError(() => new Error('governance failed')); fixture.detectChanges(); - - const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.textContent).toContain('Ready for enrichment'); - expect(compiled.textContent).toContain('Recent mastery evidence is strong enough to support optional enrichment.'); - expect(compiled.textContent).toContain('Look a little deeper at Introduction to Africa.'); + expect(coursesService.getCourseGovernanceSummary).toHaveBeenCalledWith('course-1'); + expect(text()).not.toContain('Safe publish validation'); }); - it('renders runtime support empty state when the flagship course has no support rows', () => { - coursesService.coursesResponse = of([{ id: 'course-1', title: 'Introduction to Africa', standards_metadata: { pathway_key: 'introduction-to-africa' } }]); - usersService.studentsResponse = of([{ id: 'student-1', firstname: 'Ada', lastname: 'Lovelace' }]); - analyticsService.summaryResponse = of([]); - analyticsService.runtimeSupportResponse = of([]); - + it('uses responsive educator data-list markers for progress and courses', () => { + coursesService.coursesResponse = of([{ id: 'course-1', title: 'Introduction to Africa' }]); + analyticsService.summaryResponse = of([{ student_name: 'Ada Lovelace', course_title: 'Course', progress: 40, status: 'active', last_active: null }]); fixture.detectChanges(); - const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.textContent).toContain('Runtime support will appear here'); + expect(compiled.querySelector('[data-educator-responsive="teacher-summary"]')).not.toBeNull(); + expect(compiled.querySelector('[data-educator-responsive="teacher-courses"]')).not.toBeNull(); }); - it('renders runtime support error state and retries without introducing mutations', () => { - coursesService.coursesResponse = of([{ id: 'course-1', title: 'Introduction to Africa', standards_metadata: { pathway_key: 'introduction-to-africa' } }]); - usersService.studentsResponse = of([]); - analyticsService.summaryResponse = of([]); - analyticsService.runtimeSupportResponse = throwError(() => new Error('runtime failed')); - + it('refreshes teacher summary from the visible Refresh control', () => { fixture.detectChanges(); + const refresh = Array.from((fixture.nativeElement as HTMLElement).querySelectorAll('button')).find(button => button.textContent?.trim() === 'Refresh') as HTMLButtonElement; + refresh.click(); + expect(analyticsService.getTeacherSummary).toHaveBeenCalledTimes(2); + }); - expect(analyticsService.getEducatorRuntimeSupport).toHaveBeenCalledTimes(1); - const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.textContent).toContain('We could not load learner support visibility'); + it('validates quick assignment action without duplicate submission', () => { + component.assignCourse(); + expect(toastService.show).toHaveBeenCalledWith('Select a student and a course to assign.', 'info'); + expect(coursesService.assignCourseToStudent).not.toHaveBeenCalled(); + }); - analyticsService.runtimeSupportResponse = of([]); - const retryButton = Array.from(compiled.querySelectorAll('button')).find( - (button) => button.textContent?.trim() === 'Retry' - ) as HTMLButtonElement; - retryButton.click(); - fixture.detectChanges(); + it('assigns a course only when a student and course are selected', () => { + component.selectedCourseId = 'course-1'; + component.selectedStudentId = 'student-1'; + component.assignCourse(); + expect(coursesService.assignCourseToStudent).toHaveBeenCalledWith('course-1', 'student-1'); + expect(analyticsService.getTeacherSummary).toHaveBeenCalled(); + }); - expect(analyticsService.getEducatorRuntimeSupport).toHaveBeenCalledTimes(2); - expect(coursesService.assignCourseToStudent).not.toHaveBeenCalled(); - expect(coursesService.deleteCourse).not.toHaveBeenCalled(); + it('reports assignment API failure without clearing selected values first', () => { + coursesService.assignCourseToStudent.and.returnValue(throwError(() => ({ error: { detail: 'Assignment failed.' } }))); + component.selectedCourseId = 'course-1'; + component.selectedStudentId = 'student-1'; + component.assignCourse(); + expect(toastService.show).toHaveBeenCalledWith('Assignment failed.', 'error'); + expect(component.selectedCourseId).toBe('course-1'); + expect(component.selectedStudentId).toBe('student-1'); }); }); diff --git a/frontend/src/app/pages/user-dashboard/teacher-view/teacher-view.component.ts b/frontend/src/app/pages/user-dashboard/teacher-view/teacher-view.component.ts index 4d2d5af..cb635fd 100644 --- a/frontend/src/app/pages/user-dashboard/teacher-view/teacher-view.component.ts +++ b/frontend/src/app/pages/user-dashboard/teacher-view/teacher-view.component.ts @@ -2,6 +2,7 @@ import { Component, Input, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { Router } from '@angular/router'; +import { RouterLink } from '@angular/router'; import { UserInfo } from '../../../models/user-info'; import { CoursesService } from '../../../services/courses.service'; import { Course } from '../../../models/course'; @@ -34,7 +35,7 @@ interface RuntimeInterventionCourseGroup { @Component({ selector: 'echoed-teacher-view', standalone: true, - imports: [CommonModule, FormsModule, IconModule, EchoStatePanelComponent, EchoLoadingStateComponent], + imports: [CommonModule, FormsModule, RouterLink, IconModule, EchoStatePanelComponent, EchoLoadingStateComponent], templateUrl: './teacher-view.component.html', styleUrl: './teacher-view.component.scss' }) diff --git a/frontend/src/app/services/sections.service.ts b/frontend/src/app/services/sections.service.ts index 8524f56..bd0948f 100644 --- a/frontend/src/app/services/sections.service.ts +++ b/frontend/src/app/services/sections.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs'; import { environment } from '../../environments/environment'; -import { Assignment, Section } from '../models/section'; +import { Assignment, Enrollment, Section, SectionSummary } from '../models/section'; @Injectable({ providedIn: 'root' @@ -20,6 +20,14 @@ export class SectionsService { return this.http.post
(`${this.apiUrl}/sections`, payload); } + getRoster(sectionId: string): Observable { + return this.http.get(`${this.apiUrl}/sections/${sectionId}/roster`); + } + + getSectionSummary(sectionId: string): Observable { + return this.http.get(`${this.apiUrl}/sections/${sectionId}/analytics/summary`); + } + getAssignments(sectionId: string): Observable { return this.http.get(`${this.apiUrl}/sections/${sectionId}/assignments`); } diff --git a/frontend/src/app/services/shell-navigation.service.ts b/frontend/src/app/services/shell-navigation.service.ts index 7a43b73..e2ccff3 100644 --- a/frontend/src/app/services/shell-navigation.service.ts +++ b/frontend/src/app/services/shell-navigation.service.ts @@ -54,12 +54,13 @@ export class ShellNavigationService { title: 'Teach', space: 'teach', items: [ - { label: 'Teaching Overview', route: '/home', icon: 'Home', roles: ['teacher', 'instructor'], exact: true }, - { label: 'Classes', route: '/home/sections', icon: 'Users', roles: ['teacher', 'instructor', 'org_admin'] }, - { label: 'Curriculum', route: '/home/courses', icon: 'BookOpen', roles: ['teacher', 'instructor'] }, + { label: 'Teaching Overview', route: '/teach', icon: 'Home', roles: ['teacher', 'instructor'], exact: true }, + { label: 'Classes', route: '/teach/classes', icon: 'Users', roles: ['teacher', 'instructor', 'org_admin'] }, + { label: 'Assignments', route: '/teach/assignments', icon: 'ClipboardList', roles: ['teacher', 'instructor', 'org_admin'] }, + { label: 'Curriculum', route: '/teach/curriculum', icon: 'BookOpen', roles: ['teacher', 'instructor'] }, { label: 'Learning Paths', route: '/home/programs', icon: 'ClipboardList', roles: ['teacher', 'instructor'] }, - { label: 'Learner Progress', route: '/workspace/learners', icon: 'SlidersHorizontal', roles: ['teacher', 'instructor'] }, - { label: 'Settings', route: '/home/me/preferences', icon: 'Settings', roles: ['teacher', 'instructor'] }, + { label: 'Learner Progress', route: '/teach', icon: 'SlidersHorizontal', roles: ['teacher', 'instructor'] }, + { label: 'Settings', route: '/teach/settings', icon: 'Settings', roles: ['teacher', 'instructor'] }, ], }, { 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 66840e1..0d7261f 100644 --- a/openspec/changes/overhaul-role-based-ui-ux-experience/tasks.md +++ b/openspec/changes/overhaul-role-based-ui-ux-experience/tasks.md @@ -39,10 +39,10 @@ ## 6. Teacher Experience -- [ ] 6.1 Redesign teacher Today dashboard around learner support and classes; affected screens: teacher dashboard; APIs: `/api/courses`, `/api/users/students`, `/api/analytics/teacher-summary`, governance summary; tests: teacher specs; a11y: data list summaries; responsive: priority stack; frontend-only: yes; rollback: old teacher view. -- [ ] 6.2 Redesign classes/sections and class detail; affected screens: `/home/sections`, `/home/sections/:id`, cohort aliases; APIs: sections, roster, assignments, section analytics; tests: section specs and Playwright teacher flow; a11y: tabs/list keyboard support; responsive: tabs/cards; frontend-only: yes; rollback: old templates. -- [ ] 6.3 Add guided assignment workflow over existing assignment/assign-course APIs; affected screens: class detail assignment flow; APIs: `/api/sections/{id}/assignments`, `/api/assign-course`; tests: workflow specs; a11y: stepper and validation; responsive: mobile wizard; frontend-only: yes; rollback: current inline assignment controls. -- [ ] 6.4 Redesign curriculum preview, learner progress, review/feedback, and discussion/moderation surfaces where existing APIs support them; affected screens: curriculum, assessment, discussion; APIs: courses, assessments, posts/threads; tests: targeted specs; a11y: form/table semantics; responsive: data cards; frontend-only: yes unless feedback/moderation persistence is selected; rollback: hide unsupported surfaces. +- [x] 6.1 Redesign teacher Today dashboard around learner support and classes; affected screens: teacher dashboard; APIs: `/api/courses`, `/api/users/students`, `/api/analytics/teacher-summary`, governance summary; tests: teacher specs; a11y: data list summaries; responsive: priority stack; frontend-only: yes; rollback: old teacher view. +- [x] 6.2 Redesign classes/sections and class detail; affected screens: `/home/sections`, `/home/sections/:id`, cohort aliases; APIs: sections, roster, assignments, section analytics; tests: section specs and Playwright teacher flow; a11y: tabs/list keyboard support; responsive: tabs/cards; frontend-only: yes; rollback: old templates. +- [x] 6.3 Add guided assignment workflow over existing assignment/assign-course APIs; affected screens: class detail assignment flow; APIs: `/api/sections/{id}/assignments`, `/api/assign-course`; tests: workflow specs; a11y: stepper and validation; responsive: mobile wizard; frontend-only: yes; rollback: current inline assignment controls. +- [x] 6.4 Redesign curriculum preview, learner progress, review/feedback, and discussion/moderation surfaces where existing APIs support them; affected screens: curriculum, assessment, discussion; APIs: courses, assessments, posts/threads; tests: targeted specs; a11y: form/table semantics; responsive: data cards; frontend-only: yes unless feedback/moderation persistence is selected; rollback: hide unsupported surfaces. ## 7. Administrator Experience