From ac270d033e79f5294f907d90e2d217abc8485f61 Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Sun, 27 Sep 2026 19:58:36 +1000 Subject: [PATCH 1/3] feat(ppi): peer progress and cross-unit dashboard Brings the T2 2026 ppi-cpd work from ontrack-features-t2-2026 11.0.x (reviewed and merged work) onto thoth-tech 11.0.x. Co-authored-by: maplefoxgit Co-authored-by: Tan Tai Co-authored-by: David Tenni Co-authored-by: phuctran Co-authored-by: Swyam Khare Co-authored-by: officialid130-13e13 Co-authored-by: Cilly Leang Co-authored-by: SandilBandara Co-authored-by: ReneeSleepy Co-authored-by: owens-hub-git Co-authored-by: tranhuy110 Co-authored-by: SanjanaBottu Co-authored-by: Maple 'Ryan' Fox Co-authored-by: dylancleal --- ...D-Q06-privacy-safe-screenshot-checklist.md | 45 + docs/cpd-data-source-map.md | 132 ++ docs/cpd-user-guide-handover.md | 115 ++ docs/ppi-accessibility-review-checklist.md | 121 ++ docs/ppi-f02-unit-summary-handover.md | 127 ++ docs/ppi-help-text-pack.md | 299 +++ .../peer-progress-indicator.fixtures.spec.ts | 178 ++ .../peer-progress-indicator.fixtures.ts | 160 ++ .../peer-progress-indicator-state.spec.ts | 108 ++ .../models/peer-progress-indicator-state.ts | 67 + src/app/api/models/peer-progress-indicator.ts | 40 + .../peer-progress-unit-summary-state.spec.ts | 170 ++ .../peer-progress-unit-summary-state.ts | 104 + .../api/models/peer-progress-unit-summary.ts | 28 + src/app/api/models/peer-progress.ts | 33 + src/app/api/services/mapping-fn.spec.ts | 69 + src/app/api/services/mapping-fn.ts | 42 +- .../peer-progress-indicator.service.ts | 188 ++ src/app/api/services/peer-progress.service.ts | 56 + .../peer-progress-indicator.service.spec.ts | 391 ++++ .../spec/peer-progress.service.spec.ts | 66 + .../spec/task-recommendation.service.spec.ts | 71 + .../services/task-recommendation.service.ts | 53 + .../project-card/project-card.component.html | 34 + .../project-card/project-card.component.scss | 77 + .../project-card.component.spec.ts | 74 + .../project-card/project-card.component.ts | 20 + .../services/ppi-progress-calculation.md | 15 + .../ppi-progress-calculation.service.spec.ts | 51 + .../ppi-progress-calculation.service.ts | 32 + .../f-cross-dashboard.component.html | 489 +++++ .../f-cross-dashboard.component.scss | 72 + .../f-cross-dashboard.component.spec.ts | 1678 +++++++++++++++++ .../dashboard/f-cross-dashboard.component.ts | 871 +++++++++ .../dashboard/f-cross-dashboard.route.spec.ts | 165 ++ .../dashboard-list-item.component.html | 77 + .../dashboard-list-item.component.spec.ts | 227 +++ .../dashboard-list-item.component.ts | 77 + .../expanded-list-item.component.html | 43 + .../expanded-list-item.component.scss | 3 + .../expanded-list-item.component.ts | 29 + .../add-engagement-dialog.component.html | 13 +- .../engagement-detail-dialog.component.html | 77 +- .../engagement-detail-dialog.component.scss | 4 +- .../engagement-detail-dialog.component.ts | 1 - .../engagement-passport-card.component.html | 18 +- .../engagement-passport-card.component.scss | 3 + .../peer-progress-unit-summary.component.html | 89 + .../peer-progress-unit-summary.component.scss | 3 + ...er-progress-unit-summary.component.spec.ts | 165 ++ .../peer-progress-unit-summary.component.ts | 43 + .../ppi-widget/ppi-widget.component.html | 258 +++ .../ppi-widget/ppi-widget.component.scss | 509 +++++ .../ppi-widget/ppi-widget.component.spec.ts | 324 ++++ .../ppi-widget/ppi-widget.component.ts | 203 ++ .../task-dashboard.component.spec.ts | 195 +- .../task-dashboard.component.ts | 20 + .../progress-burndown-chart.coffee | 107 -- .../progress-burndown-chart.scss | 17 - .../progress-burndown-chart.component.html | 116 +- .../progress-burndown-chart.component.scss | 149 ++ .../progress-burndown-chart.component.spec.ts | 366 ++++ .../progress-burndown-chart.component.ts | 306 ++- .../task-visualisation.component.html | 21 +- .../task-visualisation.component.scss | 27 + .../task-visualisation.component.spec.ts | 83 + .../task-visualisation.component.ts | 74 +- 67 files changed, 9596 insertions(+), 292 deletions(-) create mode 100644 docs/CPD-Q06-privacy-safe-screenshot-checklist.md create mode 100644 docs/cpd-data-source-map.md create mode 100644 docs/cpd-user-guide-handover.md create mode 100644 docs/ppi-accessibility-review-checklist.md create mode 100644 docs/ppi-f02-unit-summary-handover.md create mode 100644 docs/ppi-help-text-pack.md create mode 100644 src/app/api/fixtures/peer-progress-indicator.fixtures.spec.ts create mode 100644 src/app/api/fixtures/peer-progress-indicator.fixtures.ts create mode 100644 src/app/api/models/peer-progress-indicator-state.spec.ts create mode 100644 src/app/api/models/peer-progress-indicator-state.ts create mode 100644 src/app/api/models/peer-progress-indicator.ts create mode 100644 src/app/api/models/peer-progress-unit-summary-state.spec.ts create mode 100644 src/app/api/models/peer-progress-unit-summary-state.ts create mode 100644 src/app/api/models/peer-progress-unit-summary.ts create mode 100644 src/app/api/models/peer-progress.ts create mode 100644 src/app/api/services/mapping-fn.spec.ts create mode 100644 src/app/api/services/peer-progress-indicator.service.ts create mode 100644 src/app/api/services/peer-progress.service.ts create mode 100644 src/app/api/services/spec/peer-progress-indicator.service.spec.ts create mode 100644 src/app/api/services/spec/peer-progress.service.spec.ts create mode 100644 src/app/api/services/spec/task-recommendation.service.spec.ts create mode 100644 src/app/api/services/task-recommendation.service.ts create mode 100644 src/app/common/project-card/project-card.component.html create mode 100644 src/app/common/project-card/project-card.component.scss create mode 100644 src/app/common/project-card/project-card.component.spec.ts create mode 100644 src/app/common/project-card/project-card.component.ts create mode 100644 src/app/common/services/ppi-progress-calculation.md create mode 100644 src/app/common/services/ppi-progress-calculation.service.spec.ts create mode 100644 src/app/common/services/ppi-progress-calculation.service.ts create mode 100644 src/app/dashboard/f-cross-dashboard.component.html create mode 100644 src/app/dashboard/f-cross-dashboard.component.scss create mode 100644 src/app/dashboard/f-cross-dashboard.component.spec.ts create mode 100644 src/app/dashboard/f-cross-dashboard.component.ts create mode 100644 src/app/dashboard/f-cross-dashboard.route.spec.ts create mode 100644 src/app/dashboard/list-item/dashboard-list-item.component.html create mode 100644 src/app/dashboard/list-item/dashboard-list-item.component.spec.ts create mode 100644 src/app/dashboard/list-item/dashboard-list-item.component.ts create mode 100644 src/app/dashboard/list-item/expanded-list-item/expanded-list-item.component.html create mode 100644 src/app/dashboard/list-item/expanded-list-item/expanded-list-item.component.scss create mode 100644 src/app/dashboard/list-item/expanded-list-item/expanded-list-item.component.ts create mode 100644 src/app/projects/states/dashboard/directives/progress-dashboard/peer-progress-unit-summary/peer-progress-unit-summary.component.html create mode 100644 src/app/projects/states/dashboard/directives/progress-dashboard/peer-progress-unit-summary/peer-progress-unit-summary.component.scss create mode 100644 src/app/projects/states/dashboard/directives/progress-dashboard/peer-progress-unit-summary/peer-progress-unit-summary.component.spec.ts create mode 100644 src/app/projects/states/dashboard/directives/progress-dashboard/peer-progress-unit-summary/peer-progress-unit-summary.component.ts create mode 100644 src/app/projects/states/dashboard/directives/task-dashboard/directives/task-description-card/ppi-widget/ppi-widget.component.html create mode 100644 src/app/projects/states/dashboard/directives/task-dashboard/directives/task-description-card/ppi-widget/ppi-widget.component.scss create mode 100644 src/app/projects/states/dashboard/directives/task-dashboard/directives/task-description-card/ppi-widget/ppi-widget.component.spec.ts create mode 100644 src/app/projects/states/dashboard/directives/task-dashboard/directives/task-description-card/ppi-widget/ppi-widget.component.ts delete mode 100644 src/app/visualisations/progress-burndown-chart.coffee delete mode 100644 src/app/visualisations/progress-burndown-chart.scss create mode 100644 src/app/visualisations/progress-burndown-chart/progress-burndown-chart.component.spec.ts create mode 100644 src/app/visualisations/task-visualisation/task-visualisation.component.spec.ts diff --git a/docs/CPD-Q06-privacy-safe-screenshot-checklist.md b/docs/CPD-Q06-privacy-safe-screenshot-checklist.md new file mode 100644 index 0000000000..cfa715104c --- /dev/null +++ b/docs/CPD-Q06-privacy-safe-screenshot-checklist.md @@ -0,0 +1,45 @@ +# Cross-Project Dashboard Privacy-Safe Screenshot Checklist + +Use this checklist before sharing a dashboard screenshot in a pull request, Teams message, +report, portfolio, presentation, or handover document. + +## Before sharing + +- [ ] Real student names have been removed or replaced with mock names. +- [ ] Student IDs and email addresses are not visible. +- [ ] Personal account details and profile images are not visible. +- [ ] Unit and project information does not reveal a student's private enrolment details. +- [ ] Task titles do not contain sensitive or personally identifying information. +- [ ] Marks, feedback text, and individual assessment results are removed, permanently + redacted in the final exported image, or replaced with mock data. +- [ ] Extension details and changed due dates do not reveal private student circumstances. +- [ ] Browser tabs, URLs, tokens, local file paths, and account information are cropped out or + permanently redacted in the final exported image. +- [ ] Mock or seeded data has been used wherever possible. +- [ ] The screenshot contains only the area needed to prove the result. +- [ ] The final exported screenshot has been reopened and checked at full size to confirm that + no sensitive data remains. +- [ ] The original unredacted screenshot is not committed to Git, attached to the pull request, + or posted in a shared project channel. + +## Safe example + +A screenshot of the dashboard populated entirely with seeded demo data. For example, students +named "Test Student 1" and "Test Student 2" with placeholder emails, generic task titles, and +sample marks that were never tied to a real person. The screenshot is cropped tightly to the +feature being demonstrated, with no browser chrome, URL bar, or login or account menu visible. + +## Information that must be removed + +A screenshot taken from a real unit that shows an actual student's name, ID, or email next to +their real mark, feedback comment, or an approved extension with a reason attached. This kind +of detail must be cropped out, permanently redacted in the final exported image, or replaced +with mock data before sharing. Do not rely on light blur or pixelation to protect sensitive +information. + +## Recommendation + +The safest default for future contributors is to demonstrate features using mock data rather +than real student or account data, even when it takes a little longer to set up. When redaction +is unavoidable, export a flattened copy, reopen that final file, and check it again before +sharing. diff --git a/docs/cpd-data-source-map.md b/docs/cpd-data-source-map.md new file mode 100644 index 0000000000..83731cfe6a --- /dev/null +++ b/docs/cpd-data-source-map.md @@ -0,0 +1,132 @@ +**Cross-unit Dashboard (CPD): Data Sources & Ownership Rules** + +**Branch:** docs/cpd-data-source-map + +**Purpose:** Provide frontend, backend, and security contributors with a +single source of truth regarding dashboard data sources, role +permissions, cross-unit visibility rules. + +**Field-to-Source Mapping Table** + +This table maps user-facing fields on the Cross-unit Dashboard to their +underlying backend services and models: +| Dashboard Field / Element | Angular Component / Data Binding | API Endpoint & Method | Backend Model / Entity | Access Scope & Permissions | +| :--- | :--- | :--- | :--- | :--- | +| **Unit Code & Name** | `DashboardUnit.code`, `DashboardUnit.name` | `GET /projects` | `Unit#code`, `Unit#name` via `Project.eager_load(:unit)` | Authenticated Student (`for_user current_user`) | +| **Task Title & Subtitle** | `DashboardTask.title`, `DashboardTask.subtitle` | `GET /projects` (`include_task_definitions=true`) | `TaskDefinition#name`, `TaskDefinition#abbreviation`, `TaskDefinition#targetGradeText` | Authenticated Student (`current_user`) | +| **Task Description** | `DashboardTask.description` | `GET /projects` (`include_task_definitions=true`) | `TaskDefinition#description` | Authenticated Student (`current_user`) | +| **Task Status & Color** | `DashboardTask.status`, `statusLabel`, `color` | `GET /projects` | `Task#status`, mapped via `TaskStatus.STATUS_LABELS/COLORS` | Authenticated Student (`current_user`) | +| **New Comments Count** | `DashboardTask.comments` | `GET /projects` | `Task#numNewComments` | Authenticated Student (`current_user`) | +| **Due Date** | `DashboardTask.dueDate` | `GET /projects` | `TaskDefinition#targetDate` | Authenticated Student (`current_user`) | +| **Task Weight / Priority** | `DashboardTask.weight` | `GET /projects` | `Task#topWeight` (calculated via `project.calcTopTasks()`) | Authenticated Student (`current_user`) | +| **Project ID / Unit Key** | `DashboardUnit.projectId` | `GET /projects` | `Project#id` | Authenticated Student (`current_user`) | + +**Data Ownership, Enrolment & Visibility Rules** + +**Active vs. Inactive Enrolment Filtering** + +- **Backend Inactive Toggle:** GET /projects accepts an optional query + parameter. By default (false), only active unit projects for + current_user are fetched. + +- **Frontend Active Task Scope:** CrossDashboardComponent populates + unit tasks via project.activeTasks(), ensuring archived or inactive + tasks are excluded from the default view. + +**Client-Side State, Filtering & Sorting Rules** + +- **Hide Completed Filter (Filter.HideCompleted):** Managed via + private filters: Map\. When active, tasks with + task.status == \'complete\' (completedTypes) are removed from + unitsProcessed. + +- **Default Task Ordering:** In all sort modes, completed tasks are + pushed to the bottom of the list (completedTypes.includes(a.status) + evaluation). + +- **Due Date Sort (SortMode.SubmissionDate):** Orders active tasks + chronologically using a.dueDate.getTime() - b.dueDate.getTime() + (TaskDefinition#targetDate). + +- **Default Weight Sort (SortMode.Default):** Orders tasks by + calculated priority weight using a.weight - b.weight + (Task#topWeight). + +**Role-Based Access Control & Data Isolation** + +- **Student Access:** All dashboard data subscriptions execute through + GlobalStateService.currentUserProjects, which calls ProjectsApi + endpoints protected by authenticated?. Queries are strictly scoped + to the authenticated session (for_user current_user). + +- **Write Safeguards:** Modifying target grades or submitted grades + via PUT /projects/:id is restricted once portfolio_exists? evaluates + to true (returns HTTP 403 Forbidden). + +**Technical Gaps** + +During this documentation audit, the following technical gaps were +identified directly in the source code: + +1. **SortMode.Recommended Stub:** In + CrossDashboardComponent.processTasks(), the recommended sort mode is + stubbed out with // TODO: Connect to recommender\'s points and + returns 0. Recommended sorting currently falls back to default + ordering and is not connected to the backend Task Prioritisation + Recommender Service. + +2. **In-Memory Filtering & Performance:** Task filtering (Hide + Completed) and sorting are processed entirely client-side on the + Angular main thread (this.unitsProcessed = this.units.map(\...)). + Heavy task volumes across multiple units may impact frontend + rendering performance. + +3. **Missing Inactive Unit Filter Toggle in UI:** While ProjectsApi + supports include_inactive: Boolean, CrossDashboardComponent does not + currently expose an interface control to pass this query parameter + to GlobalStateService. + +**Data-Flow Diagram** + +```text + [ CrossDashboardComponent ] + | + | (1) Subscribes to currentUserProjects + v + [ Frontend: GlobalStateService ] + | + | (2) GET /projects?include_task_definitions=true + v + [ Backend API: ProjectsApi ] + | + | (3) Project.eager_load(:unit, :user).for_user(current_user) + v + [ PostgreSQL Database ] + | + | (4) Represents array via Entities::ProjectEntity + v + [ CrossDashboardComponent: mapProjects() & mapTasks() ] + | + | (5) In-Memory Filter/Sort via processTasks() + v + [ Rendered Dashboard Unit Cards ] +``` + +**Recommended Follow-Up Tickets** + +- **Integrate Task Prioritisation Recommender with + SortMode.Recommended:** Connect SortMode.Recommended in + CrossDashboardComponent to the backend recommender service priority + scores, replacing the current return 0 stub. + +- **Add UI Toggle for Inactive/Archived Units:** Add a filter option + to pass include_inactive=true to GlobalStateService, allowing + students to view historical unit projects. + +- **Batch Priority Points in ProjectEntity:** Extend + Entities::ProjectEntity to include pre-calculated recommender + weights per task definition to streamline frontend sorting. + +- **Unit Test Coverage for CrossDashboardComponent:** Write Angular + unit tests for toggleFilter(), setSort(), and processTasks() to + prevent sorting regressions. diff --git a/docs/cpd-user-guide-handover.md b/docs/cpd-user-guide-handover.md new file mode 100644 index 0000000000..f4fa3a7f4f --- /dev/null +++ b/docs/cpd-user-guide-handover.md @@ -0,0 +1,115 @@ +# Cross-Project Dashboard: User Guide & Contributor Handover + +This document is the user guide for students using the Cross-Project Dashboard and a handover reference for contributors maintaining the feature on `11.0.x`. The historical `feature/cross-unit` work is already integrated into that release line. + +--- + +## Part 1: Student User Guide + +### 1. Unit Scopes + +The dashboard allows students to filter visible projects using the **Unit scope** dropdown. + +- **Active units:** Displays currently enrolled and active projects. +- **Previous units:** Displays authorised projects from past teaching periods. If no historical data exists, a "No previous units are available" message is displayed. +- **All units:** Combines active and previous authorised projects in one view. + +![Unit Scope Dropdown](assets/unit-scope-dropdown.png) + +### 2. Searching and Filtering + +The dashboard features a global toolbar to filter tasks across all visible units. + +- **Global search:** Matches unit code/name and task title, abbreviation, status, target grade or due date across visible units. +- **Per-unit search:** Matches a task's title, subtitle, description, abbreviation or status inside one unit card. +- **Task statuses:** Filters by any task state offered in the dropdown. +- **Target grades:** Filters by Pass, Credit, Distinction or High Distinction tiers. +- **Date filters:** Start and end date fields filter tasks by their local due date. An invalid range is announced and is not applied. +- **Clear controls:** "Clear all" restores the Active-units scope and resets all global toolbar filters, including dates. "Clear dates" resets only the date range. Per-unit search and card filters remain independent. +- **Empty states:** The dashboard distinguishes between no available tasks and no tasks matching the current filters. + +![Global Search and Filters](assets/global-filters.png) + +### 3. Phone Layout + +On screens narrower than 640 px, the dashboard uses a stacked layout designed +for touch and keyboard use rather than a horizontally scrolling row of unit +cards. + +- The unit scope and global search stay at the top of the page. +- Secondary filters move into a **More filters** disclosure. Its label includes + the number of active secondary filters so hidden filter state remains visible. +- Projects become full-width disclosures, and opening one project closes the + previously open project. Each closed header still shows the unit, project + state, matching-task count and nearest actionable deadline. +- Task actions wrap within the viewport. The page must not require horizontal + panning at supported phone widths down to 320 px. + +At 640 px and wider, the desktop/tablet layout remains a horizontally arranged +set of independently scrolling unit cards, with the full filter toolbar visible. + +### 4. Grade Completion Summaries + +When one or more target-grade filters are selected, each visible unit card displays a **Grade task completion** summary for those tiers. + +- It displays counts and percentages, such as "Pass: 1 of 3 complete (33%)". +- These percentages represent _task completion counts_ for the selected tier. They are **not** predicted final grades or official academic marks. + +### 5. Due Date Warnings & Limitations + +The dashboard provides visual urgency indicators for approaching task deadlines. + +- **Overdue:** Displayed in red. +- **Within 24 hours:** Displayed in orange. +- **Within 3 days:** Displayed in yellow. +- **Within 7 days:** Displayed in blue. + +Warnings are shown only for tasks that have not been submitted. The dashboard uses `Task.localDueDate()`, which prefers the task-specific due date supplied by the API before falling back to the task definition. The dashboard does not independently grant or calculate extensions; the project record remains the authoritative deadline source. + +### 6. Accessibility + +- **Keyboard navigation:** Filter controls and task-detail toggles are keyboard operable, and per-unit search controls have visible focus styling. +- **Project disclosures:** Phone project headers expose their expanded state, + keep a visible focus indicator and preserve focus when filters are cleared. +- **Screen readers:** Task-detail buttons expose their expanded state and use task-specific Expand/Collapse labels. Decorative warning icons are hidden from assistive technology. +- **Non-colour meaning:** Due warnings include visible text such as "Overdue" or "Due within 3 days", so colour is not the only signal. + +### 7. Authorized Data Boundary (Privacy) + +The Cross-Project Dashboard uses the authenticated student's authorised project and task responses. + +- It displays task statuses, local due dates and definitions only for projects returned to that student by the API. +- It does not introduce official numerical marks, staff feedback comments or peer-assessment information. + +--- + +## Part 2: Contributor Handover + +### 1. Architecture & Key Files + +- **Target branch:** `11.0.x` +- **Main component:** `src/app/dashboard/f-cross-dashboard.component.ts` handles unit scopes, global and per-unit filtering, grade completion summaries, previous-unit loading and the single-open phone disclosure state. +- **Main template:** `src/app/dashboard/f-cross-dashboard.component.html` defines the filter toolbar, responsive project disclosures/cards and empty/error states. +- **Responsive styling:** `src/app/dashboard/f-cross-dashboard.component.scss` switches the stacked phone experience at 640 px while retaining the wider horizontal card layout. +- **Due-warning component:** `src/app/dashboard/list-item/dashboard-list-item.component.ts` owns warning thresholds and labels. +- **Test suites:** The adjacent component specs cover unit scopes, global/per-unit search, status/grade/date filters, summaries, warnings and accessibility state. + +### 2. Linked Tickets & Context + +- **CPD-F04:** Local frontend date-range filtering alongside unit scopes and existing task data. +- **CPD-F05:** Accessible global-toolbar date controls and targeted tests. +- **CPD-Q03:** Accessible due-date warning badges in the dashboard task presentation. +- **CPD-Q04:** Responsive-sizing regression coverage and visual evidence. +- **CPD-Q05:** UI comparison and acceptance evidence; keep the evidence link with the ticket rather than treating this guide as the comparison matrix itself. + +### 3. Known Gaps & Post-MVP Work + +The following limitations remain post-MVP work: + +1. **Deadline source:** Web displays the local due date supplied through the task model. Any new extension representation must be added to the API/model contract before the dashboard can display it. +2. **Visualisation scale:** High task volumes can create substantial scrolling. Adaptive scale controls or compact toggles remain a possible follow-up. +3. **Filter persistence:** Global toolbar filters reset after a page refresh; no account-level filter preference is stored. + +### 4. Verification Basis + +This guide was reconciled with the merged `11.0.x` implementation after the Cross-Project Dashboard closure and phone-layout merges. The two screenshots show the wider toolbar using demonstration units and contain no real student records. Before changing the guide, rerun the dashboard-focused component specs, verify the screenshots still match the current wider toolbar and unit-scope labels, and check computed layout at 320 px, 390 px, 640 px and a desktop width. diff --git a/docs/ppi-accessibility-review-checklist.md b/docs/ppi-accessibility-review-checklist.md new file mode 100644 index 0000000000..a4fd9e17e3 --- /dev/null +++ b/docs/ppi-accessibility-review-checklist.md @@ -0,0 +1,121 @@ +# Peer Progress Indicator accessibility review checklist + +**Scope:** A reusable review checklist for the existing PPI, not a completed audit, +WCAG compliance claim, or a new widget implementation. No application, API or +deployment behavior changes in this document. + +This maintains ReneeSleepy's checklist from +[web #247](https://github.com/ontrack-features-t2-2026/doubtfire-web/pull/247). +The [original PDF](https://github.com/ontrack-features-t2-2026/doubtfire-web/blob/53f6d11340eba4d53e245184507ff3f91743761b/docs/Peer%20Progress%20Indicator.pdf) +remains in its source commit. This Markdown version is the canonical checklist, +avoiding two identical PDFs and allowing accessible reading on narrow screens. + +## Reuse existing work + +- [PPI help/privacy copy](ppi-help-text-pack.md): wording intent and privacy limits. +- [Unit-summary handover](ppi-f02-unit-summary-handover.md): unit-level scope and + the distinction between demonstration data and a live API. +- [A11Y-V01 validation notes](A11Y-V01-validation-notes.md): existing evidence and + its limitations. Record new checks against the current revision. +- [Theme contract](theme/THEME-CONTRACT.md): shared Light, Dark and System tokens. +- [Current task-level template](../src/app/projects/states/dashboard/directives/task-dashboard/directives/task-description-card/ppi-widget/ppi-widget.component.html) + and [component tests](../src/app/projects/states/dashboard/directives/task-dashboard/directives/task-description-card/ppi-widget/ppi-widget.component.spec.ts). + +The task-level widget already has compact and Advanced views. It prefers a +completion percentage and retains submission percentage as a fallback for older +API responses. The separately documented unit-summary demonstration is not proof +of a live unit-level API. Do not rebuild either feature to complete this checklist. + +## Test setup and evidence + +Record the web commit, API/deploy revision if used, fixture/state, route, browser +version, OS, screen reader, viewport, theme and test date. Use synthetic accounts +and sanitised evidence; include no student names, identifiers or assessment data. + +Use desktop and narrow widths including **320 CSS pixels** and a representative +phone width such as 390. Check 200% text/browser zoom and the equivalent reflow +case of 400% at a 1280-pixel viewport. Repeat relevant controls in landscape and +with the on-screen keyboard open when it affects the workflow. + +Use keyboard-only navigation plus NVDA/Windows and VoiceOver/Safari where available. +For the mobile app, record whether the test is in the browser or the installed +standalone PWA; check iOS VoiceOver or Android TalkBack on a real device when +available. Browser viewport emulation does not establish device/screen-reader or +installed-PWA success. Record unavailable combinations as **not tested**. + +## 1. Text alternatives and state meaning + +- [ ] The compact value and progress bar have meaningful accessible names and + agree on the percentage and whether peers **completed** or **submitted**. +- [ ] Normal and real zero values are distinguished from loading, no data, + hidden/suppressed, unavailable, stale, disabled, preference-disabled and + error states. Check states supported by the current component separately. +- [ ] The Advanced breakdown provides visible status labels and numbers as well + as color. Its accessible summary conveys the same information. +- [ ] Privacy-rounded values are not presented as exact counts or stretched to + imply an exact total of 100%; independent-scale explanations are available. +- [ ] Decorative icons are hidden from assistive technology. Meaningful visual + information has a text equivalent, rather than redundant icon announcements. +- [ ] Suppressed/unavailable states expose no hidden percentage, exact cohort + size, peer identity or ranking in DOM text, ARIA labels, titles or tooltips. + +## 2. Color and contrast + +- [ ] Normal text reaches 4.5:1 and large text 3:1 against its actual rendered + background; meaningful non-text boundaries and indicators reach 3:1 where + WCAG requires it. Record foreground/background and measured ratio. +- [ ] Check Light, Dark and both resolved System appearances, including a change + in the OS preference while System is selected. +- [ ] Check muted/disabled/status text, focus indicators, bars and legend labels; + include opacity and layered backgrounds in the measurement. +- [ ] Meaning never depends only on color. Inspect a grayscale/color-vision + simulation alongside the text/semantic equivalents. + +## 3. Labels and controls + +- [ ] The widget title identifies the region and multiple widget instances do + not reuse IDs or point to another widget's title/panel. +- [ ] The Advanced control has a meaningful name, reports its state, and relates + to the controlled content. Its accessible behavior matches the rendered role. +- [ ] Help, disclosure or tooltip controls, if present, have clear names and work + with keyboard and touch; no essential explanation requires mouse hover. +- [ ] Links describe their destination. Applicable form errors explain the + problem and recovery action; mark absent forms as not applicable with a reason. + +## 4. Keyboard, touch and reflow + +- [ ] Every interactive control is reachable with Tab/Shift+Tab, has visible + focus, and responds to Enter/Space or the keys appropriate to its role. +- [ ] Expanding/collapsing Advanced preserves sensible focus and reading order; + no hidden control remains focusable and no keyboard trap is introduced. +- [ ] Touch controls remain distinguishable and usable without overlapping targets. + Verify the applicable WCAG 2.2 target-size requirement and spacing exceptions. +- [ ] At 320 and 390 CSS pixels, text wraps and controls/values remain available + without horizontal page scrolling, clipped content or overlapping controls. +- [ ] Zoom/text enlargement and portrait/landscape preserve every essential value, + explanation and action. Check the surrounding task page, not only a demo card. + +## 5. Screen-reader wording and updates + +- [ ] Heading/region structure and reading order make the title, value, comparison, + status and Advanced content understandable in context. +- [ ] Generic containers do not rely on unsupported ARIA naming; named groups and + progress bars expose their intended roles and values in the accessibility tree. +- [ ] Completion is not announced as submission, and a real 0% is not announced as + missing data. Labels do not frame the student as ahead/behind or predict a grade. +- [ ] Loading, failure and updated results can be understood without repeatedly + announcing the entire widget. Inspect the existing live region before adding + another; verify actual announcements rather than assuming ARIA attributes suffice. +- [ ] Toggle/state updates and API refreshes do not move focus or announce private + data that is absent from the visible privacy-safe state. + +## Record each result + +For every applicable check record **pass**, **fail**, **not tested**, or +**not applicable (with reason)**, plus steps, expected/observed result and a link to +the evidence. Attach screenshots for layout/contrast and a transcript or recording +for screen-reader behavior; automated component tests alone are not manual evidence. + +Record the severity and a linked fix for failures. Re-test the exact corrected +commit in the original failing environment. An untested state or device stays +untested; this checklist does not certify that PPI or OnTrack meets WCAG 2.2 AA. diff --git a/docs/ppi-f02-unit-summary-handover.md b/docs/ppi-f02-unit-summary-handover.md new file mode 100644 index 0000000000..d459b84df0 --- /dev/null +++ b/docs/ppi-f02-unit-summary-handover.md @@ -0,0 +1,127 @@ +# PPI-F02 Unit Peer Progress Summary Handover + +## Delivered scope + +PPI-F02 adds a reusable mock-backed unit-level Peer Progress Indicator summary +to the student Progress Dashboard. + +The component displays: + +- the student's own unit progress; +- a separately labelled anonymous cohort aggregate; +- genuine zero progress; +- small-cohort suppression; +- unavailable data; +- stale data; +- disabled, loading and error states. + +The presentational component receives the existing typed +`PeerProgressUnitSummaryViewModel` through an input. + +It does not make an HTTP request, create another PPI service, or calculate its +own percentage formula. + +## Existing PPI work reused + +This implementation reuses: + +- `PeerProgressUnitSummary`; +- `PeerProgressUnitSummaryViewModel`; +- `resolvePeerProgressUnitSummaryState`; +- `PeerProgressIndicatorService.getDemoUnitSummary` (only while the local demo-mode switch is on); +- `calculateCompletionPercentage`. + +The existing task-sheet PPI widget and Progress Burndown implementation were +not modified. + +## Privacy boundary + +The component does not display peer names, student IDs, project IDs, individual +task statuses, marks, feedback, rankings, or raw cohort counts. + +Suppressed, unavailable and stale states remove the anonymous cohort percentage +before it reaches the component display. + +Disabled, loading and error states do not retain percentages that could be +mistaken for current information. + +A real 0% remains visibly different from unavailable or suppressed data. + +The unit summary is not displayed when staff are viewing another student's +project. + +## Remaining live unit-level API requirement + +PPI-F02 does not deliver a live backend unit-summary endpoint. + +A future backend endpoint and frontend adapter should: + +- authenticate the caller; +- derive student identity from the authenticated session; +- **derive the target grade server-side from the student's own project, and + never accept one from the browser.** `PeerProgressIndicatorService`'s mock + takes a caller-supplied `targetGrade` and its own comment warns that this + must not become the design of a live endpoint. A unit endpoint that trusts a + browser-supplied grade lets a student read another cohort's aggregate; +- authorise access to the requested unit/project; +- enforce minimum-cohort suppression server-side; +- **add a `student_percentage` field.** The task-level contract shipped in + doubtfire-api#16 does not carry one, so the card computes it in the browser + today. That is a stopgap, not the target design; +- **quantise the student percentage into the same buckets as the cohort + percentage.** doubtfire-api#16 quantises the cohort figure to 10-point + buckets. An exact student number beside a bucketed cohort number invites a + comparison neither supports; +- avoid peer identities and raw cohort counts; +- avoid marks, feedback and individual task-state information; +- expose feature-enabled, suppression, stale/unavailable and timestamp state; +- use appropriate audit logging without recording sensitive student content. + +The future frontend adapter can replace the mock call inside +`ProgressDashboardComponent.loadPeerProgressUnitSummary()` without redesigning +the presentational component. + +### Task-level adapter nullability + +doubtfire-api#16 returns `null` for `last_updated_at` and `target_grade` in +several documented states. The task-level live adapter now maps those values to +`PeerProgressIndicator.lastUpdatedAt: string | null` and +`PeerProgressIndicator.targetGrade: number | null`. + +### The mock must not reach a production build + +The unit-level summary in this document remains mock-backed and is gated on +`environment.production`, so a built application shows the unavailable state +rather than a fabricated percentage. The task-sheet `f-ppi-widget` now uses the +authorised project/task API route and does not render a fixture in production. + +## Validation + +Targeted component tests cover: + +- normal progress; +- genuine zero; +- small-cohort suppression; +- unavailable data; +- disabled data; +- stale data; +- changed component inputs. + +Final validation should include: + +- TypeScript typecheck; +- targeted PPI-F02 tests; +- lint; +- full frontend tests; +- build; +- `git diff --check`; +- normal desktop screenshot; +- normal narrow-screen screenshot; +- suppressed or unavailable screenshot. + +## Attribution and takeover + +PPI-F02 builds on the shared unit-summary foundation merged through PR #31. + +Any earlier work from the original assignee should remain credited and preserved. +The takeover message and branch/PR search should be retained as project evidence. diff --git a/docs/ppi-help-text-pack.md b/docs/ppi-help-text-pack.md new file mode 100644 index 0000000000..4d388814e0 --- /dev/null +++ b/docs/ppi-help-text-pack.md @@ -0,0 +1,299 @@ +# PPI-D01: Peer Progress Help Text and Privacy Copy + +**Ticket:** PPI-D01 - Write the peer-comparison explanation, privacy limits and help text +**Branch:** `docs/ppi-help-privacy` +**Scope:** Documentation and copy specification only. No production widget logic, API +behaviour or data flow is changed by this document. + +**Implementation status:** + +- The task-level location map is based on the current PPI widget. +- The unit-level copy is planned for the mock-backed PPI-F02 proof of concept. +- The unit-level section does not claim that a live unit-level component or API already + exists. +- Labels, help controls and expanded explanations still require a separate frontend + implementation. + +## Acceptance criteria coverage + +| Requirement | Result | Evidence | +| ----------------------------------------------------------------------- | ------ | -------------------------------------------------------------------- | +| Label, tooltip and expanded explanation for the task-level view | Met | Section 3 | +| Label, tooltip and expanded explanation for the planned unit-level view | Met | Section 4 | +| Explain that the result is an anonymous aggregate and not a ranking | Met | Section 1 and all visible-value states | +| Cover suppression, unavailable and stale data | Met | Sections 3 and 4 | +| Use plain language without shame or pressure | Met | All copy avoids ahead, behind, winning, losing and judgement wording | +| Map copy to exact current or planned UI locations | Met | Section 2 | +| Include one annotated screenshot or wireframe | Met | `assets/ppi/ppi-d01-copy-location-wireframe.svg` | + +## 1. Shared privacy and wellbeing rules + +These rules apply to both views: + +1. A group result is shown as one combined percentage. +2. It must not display names, student IDs, project IDs, individual results, individual + task statuses, marks, feedback, raw group size or rankings. +3. A displayed `0%` is a real privacy-bucket value. It must not be presented as missing, + suppressed or unavailable data, but it may represent a small percentage that rounds to zero. +4. A suppressed result explains that the group value is hidden for privacy without + revealing the exact group size. +5. Unavailable and stale states describe a data or timing condition. They must not sound + like feedback about the student's own work. +6. The wording must not describe the student as ahead, behind, better or worse than + anyone else. +7. The percentage is not a predicted grade or a judgement about the student's chance of + receiving a grade. +8. Student-facing text must not expose technical errors, internal identifiers or backend + details. + +This wording describes the intended display boundary. Backend authorisation, aggregation +and small-group suppression must still enforce that boundary. + +## 2. Copy placement and accessibility + +### 2.1 Current task-level location + +- **Widget template:** `src/app/projects/states/dashboard/directives/task-dashboard/directives/task-description-card/ppi-widget/ppi-widget.component.html` +- **Parent mount location:** `src/app/projects/states/dashboard/directives/task-dashboard/directives/task-description-card/task-description-card.component.html` +- **State resolver:** `src/app/api/models/peer-progress-indicator-state.ts` +- **Current screen location:** inside the task description card, after the task date + controls. +- **State locations:** use the durable template cases rather than relying only on line + numbers: + - `@case ('success')` + - `@case ('no-data')` + - `@case ('hidden')` + - `@case ('unavailable')` + - `@case ('stale')` + - `@case ('disabled')` + +The task-level copy should use the three numbered locations in the wireframe: + +1. A visible label naming the information. +2. A short tooltip or help summary opened from an accessible help control. +3. An expanded explanation in a popover, panel or other accessible disclosure. + +### 2.2 Planned unit-level location + +The unit-level copy is planned for the PPI-F02 mock-backed proof of concept. It should use +the same three locations: + +1. **Label:** at the top of the unit progress summary card. +2. **Tooltip or short help text:** opened from the card's help control. +3. **Expanded explanation:** below the two progress values or inside an accessible + disclosure panel. + +The planned card should keep these values separate: + +- **My progress** +- **Anonymous group progress** + +The earlier unit-level wireframe used one anonymous Ready-for-Feedback value. The current +PPI-F02 scope requires the student's own progress and the anonymous group value to be +separately labelled. The annotated wireframe below keeps the earlier simple card style +while showing the current two-value boundary. + +![Annotated PPI copy-location wireframe](assets/ppi/ppi-d01-copy-location-wireframe.svg) + +### 2.3 Accessibility requirement for implementation + +The future help control must: + +- be a real button with a useful accessible name; +- work with keyboard, touch, and screen-reader use; +- not depend on mouse hover alone; +- expose expanded text in a predictable reading and focus order; +- close without trapping keyboard focus; +- remain understandable without colour; +- avoid using an icon as the only explanation. + +Suggested accessible name: + +> About peer progress + +## 3. Task-level copy + +### 3.1 Normal result + +**Template state:** `@case ('success')` + +- **Label:** Peer task progress +- **Tooltip or short help text:** Shows the percentage of students in your + privacy-protected target-grade group who have submitted this task. +- **Expanded explanation:** This is one combined percentage for students aiming for the + same target grade. It does not show names, individual results, raw group size or + rankings. It does not assess your own work or predict your grade. + +### 3.2 Rounded zero result + +**Template state:** `@case ('no-data')` + +- **Label:** Peer task progress +- **Tooltip or short help text:** The privacy-protected group percentage currently rounds + to 0%. This is a displayed value, not missing data. +- **Expanded explanation:** The API rounds percentages into privacy-safe bands, so 0% may + include a small number of submissions. It is not feedback about your own progress. The + displayed percentage may change as submissions are made. + +### 3.3 Small-group suppression + +**Template state:** `@case ('hidden')` + +- **Label:** Peer task progress hidden +- **Tooltip or short help text:** The group percentage is hidden because the group is too + small to show safely. +- **Expanded explanation:** OnTrack hides the group percentage to protect student + privacy. It does not show the hidden value, exact group size, names or individual + results. This is a privacy safeguard, not an error or a judgement about your work. + +### 3.4 Unavailable data + +**Template state:** `@case ('unavailable')` + +- **Label:** Peer task progress unavailable +- **Tooltip or short help text:** Current group progress is not available. This does not + describe your own progress. +- **Expanded explanation:** OnTrack cannot show a current group percentage for this task + right now. This is a data-availability state, not feedback about your work. Check again + later. + +### 3.5 Stale data + +**Template state:** `@case ('stale')` + +- **Label:** Peer task progress not current +- **Tooltip or short help text:** The group value has not refreshed recently, so it is not + shown as current. +- **Expanded explanation:** The latest group percentage may be out of date. OnTrack does + not present it as current because that could be misleading. This is a timing issue, not + feedback about your own progress. + +### 3.6 Feature disabled + +**Template state:** `@case ('disabled')` + +- **Label:** Peer task progress off +- **Tooltip or short help text:** Peer progress is turned off for this unit. +- **Expanded explanation:** Peer comparison is not shown for this unit. This message only + describes whether the feature is available. It is not a judgement about your work, + submission or progress. + +### 3.7 Supporting loading and error copy + +These states already exist in the task-level widget and should use the same accessible +help pattern when the copy is implemented. + +- **Loading:** Loading peer progress. +- **Error:** Could not load peer progress. Please try again. + +The error message must remain generic and must not reveal peer details, internal IDs, +authorisation rules or backend errors. + +## 4. Planned unit-level copy + +This section is design-ready copy for the mock-backed PPI-F02 proof of concept. It is not a +claim that live unit-level data has been delivered. + +### 4.1 Normal result + +- **Label:** Unit progress overview +- **Tooltip or short help text:** Shows your own unit progress beside one + privacy-protected group percentage. +- **Expanded explanation:** My progress and Anonymous group progress are separate values. + They use the agreed unit-progress measure. The group value is one combined percentage + and does not show names, individual results, raw group size, marks, feedback or + rankings. + +### 4.2 Rounded zero group result + +- **Label:** Unit progress overview +- **Tooltip or short help text:** Anonymous group progress currently rounds to 0%. This is + a displayed value, not missing data. +- **Expanded explanation:** Your own progress remains separate from the group result. A + displayed group value of 0% may include a small value that rounds to zero. It is not + feedback about your work and does not predict your grade. + +### 4.3 Small-group suppression + +- **Label:** Anonymous group progress hidden +- **Tooltip or short help text:** The group percentage is hidden because the group is too + small to show safely. +- **Expanded explanation:** Your own progress may still be shown when it is available, but + the anonymous group percentage is hidden for privacy. OnTrack does not show the hidden + percentage, exact group size, names or individual results. + +### 4.4 Unavailable group data + +- **Label:** Anonymous group progress unavailable +- **Tooltip or short help text:** Your own progress may remain visible, but current group + progress is unavailable. +- **Expanded explanation:** OnTrack cannot show a current anonymous group percentage + right now. This does not change or assess your own progress. Check again later. + +### 4.5 Stale group data + +- **Label:** Anonymous group progress not current +- **Tooltip or short help text:** The group value has not refreshed recently and is not + shown as current. +- **Expanded explanation:** The latest anonymous group percentage may be out of date. + OnTrack does not present it as current because that could be misleading. Your own + progress remains a separate value. + +### 4.6 Feature disabled + +- **Label:** Unit peer progress off +- **Tooltip or short help text:** Peer progress is turned off for this unit. +- **Expanded explanation:** The unit-level peer comparison is not shown. This message only + describes feature availability. It is not feedback about your work, submission or + progress. + +### 4.7 Supporting loading and error copy + +- **Loading:** Loading unit progress. +- **Error:** Could not load unit progress. Please try again. + +A failed request or unit change must not leave the previous unit's anonymous group value +visible as though it were current. + +## 5. Implementation status and handover + +| Item | Status after PPI-D01 | +| ------------------------------------------------- | ---------------------------------------------------- | +| Task-level state copy | Complete in this document | +| Task-level label, help control and expanded panel | Planned frontend implementation | +| Unit-level state copy | Complete as planned PPI-F02 copy | +| Unit-level component | Separate PPI-F02 work | +| Live unit-level API | Future work and not claimed here | +| Annotated copy-location evidence | Complete in the sanitised SVG wireframe | +| Privacy and authorisation enforcement | Separate backend and security testing responsibility | + +### Known implementation follow-ups + +1. The current task-level template contains a visible message but no dedicated accessible + help control or expanded explanation location. +2. The genuine zero message is currently handled differently from several other states. + A frontend follow-up should decide whether all state messages use one consistent + source. +3. Stale and unavailable states need clearly different visible messages when this copy is + implemented. +4. The disabled state must remain neutral. The frontend must not claim that a particular + role or person disabled the feature unless the trusted response explicitly supplies + that information. +5. The exact unit-progress metric must be confirmed by the implementation and calculation + tickets before the planned unit-level wording is wired into production. + +## 6. Evidence and attribution + +- Original PPI-D01 task-level research and copy: + - David Tenni + - commits `c98e044` and `c5b379d` +- Earlier unit-level visual baseline and usability work: + - Umeda Ranuluge + - used as a design reference without copying personal identifying details +- Acceptance-criteria completion, privacy wording correction, sanitised annotated + wireframe and handover: + - Maple Fox +- Documentation: + - `docs/ppi-help-text-pack.md` +- Annotated wireframe: + - `docs/assets/ppi/ppi-d01-copy-location-wireframe.svg` diff --git a/src/app/api/fixtures/peer-progress-indicator.fixtures.spec.ts b/src/app/api/fixtures/peer-progress-indicator.fixtures.spec.ts new file mode 100644 index 0000000000..1e6f5cb0d3 --- /dev/null +++ b/src/app/api/fixtures/peer-progress-indicator.fixtures.spec.ts @@ -0,0 +1,178 @@ +import {describe, expect, it} from 'vitest'; +import {PeerProgressIndicator} from '../models/peer-progress-indicator'; +import { + FIXTURE_DISABLED, + FIXTURE_MALFORMED, + FIXTURE_NORMAL, + FIXTURE_STALE, + FIXTURE_SUPPRESSED, + FIXTURE_UNAVAILABLE, + FIXTURE_ZERO_PERCENT, +} from './peer-progress-indicator.fixtures'; + +const assertContractShape = (fixture: PeerProgressIndicator) => { + expect(typeof fixture.taskDefinitionId).toBe('number'); + expect(typeof fixture.unitId).toBe('number'); + expect(fixture.targetGrade === null || typeof fixture.targetGrade === 'number').toBe(true); + + // submittedPercentage can be number or null + expect( + fixture.submittedPercentage === null || typeof fixture.submittedPercentage === 'number', + ).toBe(true); + expect( + fixture.completedPercentage === null || typeof fixture.completedPercentage === 'number', + ).toBe(true); + expect(typeof fixture.distributionAvailable).toBe('boolean'); + expect(Array.isArray(fixture.statusDistribution)).toBe(true); + expect(typeof fixture.isUserEnabled).toBe('boolean'); + + expect(typeof fixture.isSuppressed).toBe('boolean'); + expect(typeof fixture.isStale).toBe('boolean'); + expect(typeof fixture.isFeatureEnabled).toBe('boolean'); + + expect(fixture.lastUpdatedAt === null || typeof fixture.lastUpdatedAt === 'string').toBe(true); + expect(typeof fixture.unavailableMessage).toBe('string'); + expect(fixture.unavailableReason === null || typeof fixture.unavailableReason === 'string').toBe( + true, + ); + expect( + fixture.distributionUnavailableReason === null || + typeof fixture.distributionUnavailableReason === 'string', + ).toBe(true); +}; + +describe('PeerProgressIndicator Fixture Regression Tests', () => { + // Contract Shape Tests + it('NORMAL fixture matches the contract shape', () => { + assertContractShape(FIXTURE_NORMAL); + }); + + it('ZERO_PERCENT fixture matches the contract shape', () => { + assertContractShape(FIXTURE_ZERO_PERCENT); + }); + + it('SUPPRESSED fixture matches the contract shape', () => { + assertContractShape(FIXTURE_SUPPRESSED); + }); + + it('UNAVAILABLE fixture matches the contract shape', () => { + assertContractShape(FIXTURE_UNAVAILABLE); + }); + + it('DISABLED fixture matches the contract shape', () => { + assertContractShape(FIXTURE_DISABLED); + }); + + it('STALE fixture matches the contract shape', () => { + assertContractShape(FIXTURE_STALE); + }); + + // Safe-State Behaviour Tests + it('NORMAL fixture has a valid percentage', () => { + expect(FIXTURE_NORMAL.submittedPercentage).toBeGreaterThan(0); + expect(FIXTURE_NORMAL.isSuppressed).toBe(false); + expect(FIXTURE_NORMAL.isFeatureEnabled).toBe(true); + }); + + it('NORMAL fixture contains the complete, 10-point-quantised canonical status vector', () => { + expect(FIXTURE_NORMAL.statusDistribution).toHaveLength(15); + expect(new Set(FIXTURE_NORMAL.statusDistribution.map((entry) => entry.status)).size).toBe(15); + expect(FIXTURE_NORMAL.statusDistribution.every((entry) => entry.percentage % 10 === 0)).toBe( + true, + ); + expect(FIXTURE_NORMAL.statusDistribution).toContainEqual({ + status: 'fix_and_resubmit', + percentage: 10, + }); + expect(FIXTURE_NORMAL.statusDistribution).toContainEqual({ + status: 'redo', + percentage: 10, + }); + }); + + it('ZERO_PERCENT fixture keeps a displayed 0% distinct from unavailable data', () => { + expect(FIXTURE_ZERO_PERCENT.submittedPercentage).toBe(0); + expect(FIXTURE_ZERO_PERCENT.isSuppressed).toBe(false); + }); + + it('SUPPRESSED fixture hides percentage and uses safe wording', () => { + expect(FIXTURE_SUPPRESSED.submittedPercentage).toBeNull(); + expect(FIXTURE_SUPPRESSED.isSuppressed).toBe(true); + expect(FIXTURE_SUPPRESSED.unavailableMessage).toContain('Not enough students'); + }); + + it('UNAVAILABLE fixture hides percentage but is not suppressed', () => { + expect(FIXTURE_UNAVAILABLE.submittedPercentage).toBeNull(); + expect(FIXTURE_UNAVAILABLE.isSuppressed).toBe(false); + expect(FIXTURE_UNAVAILABLE.unavailableMessage).toContain('Progress unavailable'); + }); + + it('DISABLED fixture hides percentage and marks feature disabled', () => { + expect(FIXTURE_DISABLED.submittedPercentage).toBeNull(); + expect(FIXTURE_DISABLED.isFeatureEnabled).toBe(false); + }); + + it('STALE fixture marks data as stale', () => { + expect(FIXTURE_STALE.isStale).toBe(true); + }); + + // Privacy Tests + it('No fixture leaks peer identities or raw cohort counts', () => { + const fixtures = [ + FIXTURE_NORMAL, + FIXTURE_ZERO_PERCENT, + FIXTURE_SUPPRESSED, + FIXTURE_UNAVAILABLE, + FIXTURE_DISABLED, + FIXTURE_STALE, + ]; + + fixtures.forEach((f) => { + expect(f).not.toHaveProperty('peerName'); + expect(f).not.toHaveProperty('studentId'); + expect(f).not.toHaveProperty('cohortCount'); + expect(f).not.toHaveProperty('marks'); + expect(f).not.toHaveProperty('feedback'); + expect(f.statusDistribution).not.toContainEqual( + expect.objectContaining({studentId: expect.anything()}), + ); + }); + }); + + // Malformed Safe-Failure Tests + it('MALFORMED fixture contains invalid values for safe-failure testing', () => { + const malformed = FIXTURE_MALFORMED as Partial; + + // invalid numeric values + expect(malformed.submittedPercentage).toBeNaN(); + expect(Number.isFinite(malformed.submittedPercentage as number)).toBe(false); + expect(typeof malformed.distributionAvailable).not.toBe('boolean'); + + // invalid ranges + // expect((malformed.submittedPercentage as number) < 0).toBe(true); + // expect((malformed.submittedPercentage as number) > 100).toBe(true); + + // invalid timestamp + expect(isNaN(Date.parse(malformed.lastUpdatedAt as string))).toBe(true); + + // invalid IDs + expect(typeof malformed.taskDefinitionId).not.toBe('number'); + expect(typeof malformed.unitId).not.toBe('number'); + + // invalid target grade + expect(typeof malformed.targetGrade).not.toBe('number'); + }); + + it('MALFORMED fixture must not allow impossible state combinations', () => { + const malformed = FIXTURE_MALFORMED as Partial; + + // suppressed must be boolean + expect(typeof malformed.isSuppressed).not.toBe('boolean'); + + // stale must be boolean + expect(typeof malformed.isStale).not.toBe('boolean'); + + // featureEnabled must be boolean + expect(typeof malformed.isFeatureEnabled).not.toBe('boolean'); + }); +}); diff --git a/src/app/api/fixtures/peer-progress-indicator.fixtures.ts b/src/app/api/fixtures/peer-progress-indicator.fixtures.ts new file mode 100644 index 0000000000..17c525f52e --- /dev/null +++ b/src/app/api/fixtures/peer-progress-indicator.fixtures.ts @@ -0,0 +1,160 @@ +import {PeerProgressIndicator} from '../models/peer-progress-indicator'; + +export const FIXTURE_NORMAL: PeerProgressIndicator = { + taskDefinitionId: 12, + unitId: 5, + targetGrade: 2, + submittedPercentage: 60, + completedPercentage: 10, + distributionAvailable: true, + statusDistribution: [ + {status: 'not_started', percentage: 20}, + {status: 'feedback_exceeded', percentage: 0}, + {status: 'redo', percentage: 10}, + {status: 'need_help', percentage: 0}, + {status: 'working_on_it', percentage: 20}, + {status: 'fix_and_resubmit', percentage: 10}, + {status: 'ready_for_feedback', percentage: 20}, + {status: 'discuss', percentage: 0}, + {status: 'demonstrate', percentage: 0}, + {status: 'complete', percentage: 10}, + {status: 'fail', percentage: 10}, + {status: 'time_exceeded', percentage: 0}, + {status: 'assess_in_portfolio', percentage: 0}, + {status: 'attention_required', percentage: 0}, + {status: 'rediscuss', percentage: 0}, + ], + isUserEnabled: true, + isSuppressed: false, + isStale: false, + isFeatureEnabled: true, + lastUpdatedAt: '2026-08-14T03:15:00Z', + unavailableMessage: '', + unavailableReason: null, + distributionUnavailableReason: null, +}; + +export const FIXTURE_ZERO_PERCENT: PeerProgressIndicator = { + taskDefinitionId: 12, + unitId: 5, + targetGrade: 2, + submittedPercentage: 0, + completedPercentage: 0, + distributionAvailable: true, + statusDistribution: [ + {status: 'not_started', percentage: 50}, + {status: 'feedback_exceeded', percentage: 0}, + {status: 'redo', percentage: 0}, + {status: 'need_help', percentage: 10}, + {status: 'working_on_it', percentage: 30}, + {status: 'fix_and_resubmit', percentage: 10}, + {status: 'ready_for_feedback', percentage: 0}, + {status: 'discuss', percentage: 0}, + {status: 'demonstrate', percentage: 0}, + {status: 'complete', percentage: 0}, + {status: 'fail', percentage: 0}, + {status: 'time_exceeded', percentage: 0}, + {status: 'assess_in_portfolio', percentage: 0}, + {status: 'attention_required', percentage: 0}, + {status: 'rediscuss', percentage: 0}, + ], + isUserEnabled: true, + isSuppressed: false, + isStale: false, + isFeatureEnabled: true, + lastUpdatedAt: '2026-08-14T03:15:00Z', + unavailableMessage: '', + unavailableReason: null, + distributionUnavailableReason: null, +}; + +export const FIXTURE_SUPPRESSED: PeerProgressIndicator = { + taskDefinitionId: 12, + unitId: 5, + targetGrade: 2, + submittedPercentage: null, + completedPercentage: null, + distributionAvailable: false, + statusDistribution: [], + isUserEnabled: true, + isSuppressed: true, + isStale: false, + isFeatureEnabled: true, + lastUpdatedAt: '2026-08-14T03:15:00Z', + unavailableMessage: 'Not enough students to show progress.', + unavailableReason: 'insufficient_cohort', + distributionUnavailableReason: 'insufficient_cohort', +}; + +export const FIXTURE_UNAVAILABLE: PeerProgressIndicator = { + taskDefinitionId: 12, + unitId: 5, + targetGrade: 2, + submittedPercentage: null, + completedPercentage: null, + distributionAvailable: false, + statusDistribution: [], + isUserEnabled: true, + isSuppressed: false, + isStale: false, + isFeatureEnabled: true, + lastUpdatedAt: '2026-08-14T03:15:00Z', + unavailableMessage: 'Progress unavailable.', + unavailableReason: 'snapshot_unavailable', + distributionUnavailableReason: 'snapshot_unavailable', +}; + +export const FIXTURE_DISABLED: PeerProgressIndicator = { + taskDefinitionId: 12, + unitId: 5, + targetGrade: 2, + submittedPercentage: null, + completedPercentage: null, + distributionAvailable: false, + statusDistribution: [], + isUserEnabled: true, + isSuppressed: false, + isStale: false, + isFeatureEnabled: false, + lastUpdatedAt: '2026-08-14T03:15:00Z', + unavailableMessage: 'Peer Progress Indicator is disabled for this unit.', + unavailableReason: 'feature_disabled', + distributionUnavailableReason: 'feature_disabled', +}; + +export const FIXTURE_STALE: PeerProgressIndicator = { + taskDefinitionId: 12, + unitId: 5, + targetGrade: 2, + submittedPercentage: null, + completedPercentage: null, + distributionAvailable: false, + statusDistribution: [], + isUserEnabled: true, + isSuppressed: false, + isStale: true, + isFeatureEnabled: true, + lastUpdatedAt: '2026-08-14T03:15:00Z', + unavailableMessage: 'Progress data is stale.', + unavailableReason: 'stale', + distributionUnavailableReason: 'stale', +}; + +// Malformed fixture for safe-failure tests +export const FIXTURE_MALFORMED: unknown = { + taskDefinitionId: null, + unitId: undefined, + targetGrade: 'wrong-type', + submittedPercentage: NaN, + completedPercentage: -1, + distributionAvailable: 'yes', + statusDistribution: [{status: 'not_real', percentage: 200}], + isUserEnabled: 'yes', + isSuppressed: 'nope', + isStale: 123, + isFeatureEnabled: 'false', + lastUpdatedAt: 'not-a-date', + unavailableMessage: 999, + unavailableReason: 'raw server detail', + distributionUnavailableReason: 'raw server detail', +}; diff --git a/src/app/api/models/peer-progress-indicator-state.spec.ts b/src/app/api/models/peer-progress-indicator-state.spec.ts new file mode 100644 index 0000000000..65ea141544 --- /dev/null +++ b/src/app/api/models/peer-progress-indicator-state.spec.ts @@ -0,0 +1,108 @@ +import {describe, expect, it} from 'vitest'; +import { + DISABLED_STATE, + NORMAL_STATE, + STALE_STATE, + SUPPRESSED_STATE, + UNAVAILABLE_STATE, + USER_DISABLED_STATE, + ZERO_PERCENT_STATE, +} from 'src/app/demo/fixtures/peer-progress-demo.fixtures'; +import {resolvePeerProgressState} from './peer-progress-indicator-state'; + +describe('resolvePeerProgressState', () => { + it('returns loading while the request is in flight, ignoring any data passed in', () => { + const result = resolvePeerProgressState(true, null, NORMAL_STATE); + expect(result.state).toBe('loading'); + expect(result.data).toBeNull(); + }); + + it('returns error when the request fails, and does not leak the previous data', () => { + const result = resolvePeerProgressState(false, new Error('network down'), NORMAL_STATE); + expect(result.state).toBe('error'); + expect(result.data).toBeNull(); + }); + + it('returns success for a normal response', () => { + const result = resolvePeerProgressState(false, null, NORMAL_STATE); + expect(result.state).toBe('success'); + expect(result.data).toEqual(NORMAL_STATE); + }); + + it('keeps a rounded zero distinct from unavailable data', () => { + const result = resolvePeerProgressState(false, null, ZERO_PERCENT_STATE); + expect(result.state).toBe('no-data'); + }); + + it('returns hidden with the API message for a suppressed response', () => { + const result = resolvePeerProgressState(false, null, SUPPRESSED_STATE); + expect(result.state).toBe('hidden'); + expect(result.message).toBe(SUPPRESSED_STATE.unavailableMessage); + }); + + it('prioritises privacy suppression when a response is also stale', () => { + const result = resolvePeerProgressState(false, null, { + ...STALE_STATE, + isSuppressed: true, + }); + + expect(result.state).toBe('hidden'); + expect(result.data?.submittedPercentage).toBeNull(); + }); + + it('returns unavailable for a generically unavailable response', () => { + const result = resolvePeerProgressState(false, null, UNAVAILABLE_STATE); + expect(result.state).toBe('unavailable'); + }); + + it('returns disabled when the unit has turned the feature off', () => { + const result = resolvePeerProgressState(false, null, DISABLED_STATE); + expect(result.state).toBe('disabled'); + expect(result.message).toBe(DISABLED_STATE.unavailableMessage); + }); + + it('returns preference-disabled without compact or detailed values when the user opts out', () => { + const result = resolvePeerProgressState(false, null, USER_DISABLED_STATE); + + expect(result.state).toBe('preference-disabled'); + expect(result.data?.completedPercentage).toBeNull(); + expect(result.data?.submittedPercentage).toBeNull(); + expect(result.data?.statusDistribution).toEqual([]); + expect(result.message).toContain('profile settings'); + }); + + it('keeps an old API response usable as submission progress when completion is absent', () => { + const result = resolvePeerProgressState(false, null, { + ...NORMAL_STATE, + completedPercentage: null, + }); + + expect(result.state).toBe('success'); + expect(result.data?.submittedPercentage).toBe(60); + }); + + it('returns stale without exposing a percentage when data is outdated', () => { + const result = resolvePeerProgressState(false, null, STALE_STATE); + + expect(result.state).toBe('stale'); + expect(result.data?.submittedPercentage).toBeNull(); + expect(result.message).toBe(STALE_STATE.unavailableMessage); + }); + + it('transitions from loading to success without carrying over a stale value', () => { + const loadingResult = resolvePeerProgressState(true, null, null); + expect(loadingResult.data).toBeNull(); + + const successResult = resolvePeerProgressState(false, null, NORMAL_STATE); + expect(successResult.data).toEqual(NORMAL_STATE); + }); + + it('transitions from loading to error without carrying over a stale value', () => { + const loadingResult = resolvePeerProgressState(true, null, NORMAL_STATE); + expect(loadingResult.data).toBeNull(); + + const errorResult = resolvePeerProgressState(false, new Error('timeout'), null); + expect(errorResult.state).toBe('error'); + expect(errorResult.data).toBeNull(); + }); +}); diff --git a/src/app/api/models/peer-progress-indicator-state.ts b/src/app/api/models/peer-progress-indicator-state.ts new file mode 100644 index 0000000000..2f29e3b4fe --- /dev/null +++ b/src/app/api/models/peer-progress-indicator-state.ts @@ -0,0 +1,67 @@ +import {PeerProgressIndicator} from './peer-progress-indicator'; + +export type PeerProgressUiState = + | 'loading' + | 'success' + | 'no-data' + | 'hidden' + | 'preference-disabled' + | 'unavailable' + | 'disabled' + | 'stale' + | 'error'; + +export interface PeerProgressViewModel { + state: PeerProgressUiState; + data: PeerProgressIndicator | null; + message: string | null; +} + +const GENERIC_ERROR_MESSAGE = 'Could not load peer progress. Please try again.'; + +// Prevents old peer data from being visible after an error or new request. +export function resolvePeerProgressState( + loading: boolean, + error: unknown | null, + data: PeerProgressIndicator | null, +): PeerProgressViewModel { + if (loading) { + return {state: 'loading', data: null, message: null}; + } + + if (error || !data) { + return {state: 'error', data: null, message: GENERIC_ERROR_MESSAGE}; + } + + if (!data.isUserEnabled) { + return { + state: 'preference-disabled', + data, + message: 'Peer progress is turned off in your profile settings.', + }; + } + + if (!data.isFeatureEnabled) { + return {state: 'disabled', data, message: data.unavailableMessage}; + } + + if (data.isSuppressed) { + return {state: 'hidden', data, message: data.unavailableMessage}; + } + + if (data.isStale) { + return {state: 'stale', data, message: data.unavailableMessage}; + } + + const compactPercentage = data.completedPercentage ?? data.submittedPercentage; + + if (compactPercentage === null) { + return {state: 'unavailable', data, message: data.unavailableMessage}; + } + + if (compactPercentage === 0) { + return {state: 'no-data', data, message: null}; + } + + return {state: 'success', data, message: null}; +} diff --git a/src/app/api/models/peer-progress-indicator.ts b/src/app/api/models/peer-progress-indicator.ts new file mode 100644 index 0000000000..6f0bfc0f26 --- /dev/null +++ b/src/app/api/models/peer-progress-indicator.ts @@ -0,0 +1,40 @@ +import {TaskStatusEnum} from './task-status'; + +export interface PeerProgressStatusDistributionEntry { + status: TaskStatusEnum; + percentage: number; +} + +export type PeerProgressUnavailableReason = + | 'user_disabled' + | 'feature_disabled' + | 'target_grade_unavailable' + | 'snapshot_unavailable' + | 'insufficient_cohort' + | 'aggregation_incomplete' + | 'stale'; + +export type PeerProgressDistributionUnavailableReason = + | 'detailed_data_unavailable' + | 'privacy_protection' + | PeerProgressUnavailableReason; + +export interface PeerProgressIndicator { + taskDefinitionId: number; // ID of the task definition + unitId: number; // ID of the unit + targetGrade: number | null; // Valid server-side target grade, or null when unavailable + + submittedPercentage: number | null; // null when suppressed/unavailable/disabled + completedPercentage: number | null; // null when suppressed/unavailable/disabled + distributionAvailable: boolean; // false when a detailed vector cannot be shown safely + statusDistribution: PeerProgressStatusDistributionEntry[]; // privacy-safe, quantised values only + isUserEnabled: boolean; // false when the student's profile preference disables PPI + isSuppressed: boolean; // true when cohort too small + isStale: boolean; // true when data is outdated + isFeatureEnabled: boolean; // false when unit disables PPI + + lastUpdatedAt: string | null; // ISO timestamp, or null when no snapshot was used + unavailableMessage: string; // safe message for suppressed/unavailable/stale/disabled + unavailableReason: PeerProgressUnavailableReason | null; + distributionUnavailableReason: PeerProgressDistributionUnavailableReason | null; +} diff --git a/src/app/api/models/peer-progress-unit-summary-state.spec.ts b/src/app/api/models/peer-progress-unit-summary-state.spec.ts new file mode 100644 index 0000000000..ec0ad7d4c5 --- /dev/null +++ b/src/app/api/models/peer-progress-unit-summary-state.spec.ts @@ -0,0 +1,170 @@ +import {describe, expect, it} from 'vitest'; +import { + DISABLED_STATE, + NORMAL_STATE, + STALE_STATE, + SUPPRESSED_STATE, + UNAVAILABLE_STATE, + ZERO_PERCENT_STATE, +} from 'src/app/demo/fixtures/peer-progress-demo.fixtures'; +import {PeerProgressUnitSummary} from './peer-progress-unit-summary'; +import {resolvePeerProgressUnitSummaryState} from './peer-progress-unit-summary-state'; + +type SharedCohortState = + | typeof NORMAL_STATE + | typeof ZERO_PERCENT_STATE + | typeof SUPPRESSED_STATE + | typeof UNAVAILABLE_STATE + | typeof STALE_STATE + | typeof DISABLED_STATE; + +function toUnitSummary( + cohort: SharedCohortState, + studentPercentage: number | null, +): PeerProgressUnitSummary { + return { + unitId: cohort.unitId, + targetGrade: cohort.targetGrade, + studentPercentage, + submittedPercentage: cohort.submittedPercentage, + isSuppressed: cohort.isSuppressed, + isStale: cohort.isStale, + isFeatureEnabled: cohort.isFeatureEnabled, + lastUpdatedAt: cohort.lastUpdatedAt, + unavailableMessage: cohort.unavailableMessage, + }; +} + +describe('resolvePeerProgressUnitSummaryState', () => { + it('clears data while loading', () => { + const result = resolvePeerProgressUnitSummaryState(true, null, toUnitSummary(NORMAL_STATE, 30)); + + expect(result.state).toBe('loading'); + expect(result.data).toBeNull(); + }); + + it('clears previous data when a request fails', () => { + const result = resolvePeerProgressUnitSummaryState( + false, + new Error('network down'), + toUnitSummary(NORMAL_STATE, 30), + ); + + expect(result.state).toBe('error'); + expect(result.data).toBeNull(); + expect(result.message).toBe('Could not load peer progress. Please try again.'); + }); + + it('returns success with student and cohort percentages kept separate', () => { + const summary = toUnitSummary(NORMAL_STATE, 30); + const result = resolvePeerProgressUnitSummaryState(false, null, summary); + + expect(result.state).toBe('success'); + expect(result.data?.studentPercentage).toBe(30); + expect(result.data?.submittedPercentage).toBe(NORMAL_STATE.submittedPercentage); + }); + + it('keeps a genuine cohort 0% different from unavailable data', () => { + const result = resolvePeerProgressUnitSummaryState( + false, + null, + toUnitSummary(ZERO_PERCENT_STATE, 0), + ); + + expect(result.state).toBe('no-data'); + expect(result.data?.studentPercentage).toBe(0); + expect(result.data?.submittedPercentage).toBe(0); + }); + + it('removes a cohort percentage from a malformed suppressed response', () => { + const unsafeSummary: PeerProgressUnitSummary = { + ...toUnitSummary(SUPPRESSED_STATE, 30), + submittedPercentage: 65, + }; + + const result = resolvePeerProgressUnitSummaryState(false, null, unsafeSummary); + + expect(result.state).toBe('hidden'); + expect(result.data?.studentPercentage).toBe(30); + expect(result.data?.submittedPercentage).toBeNull(); + expect(result.message).toBe(SUPPRESSED_STATE.unavailableMessage); + }); + + it('keeps own progress but hides unavailable cohort progress', () => { + const result = resolvePeerProgressUnitSummaryState( + false, + null, + toUnitSummary(UNAVAILABLE_STATE, 30), + ); + + expect(result.state).toBe('unavailable'); + expect(result.data?.studentPercentage).toBe(30); + expect(result.data?.submittedPercentage).toBeNull(); + }); + + it('clears both percentages when the feature is disabled', () => { + const unsafeSummary: PeerProgressUnitSummary = { + ...toUnitSummary(DISABLED_STATE, 30), + submittedPercentage: 65, + }; + + const result = resolvePeerProgressUnitSummaryState(false, null, unsafeSummary); + + expect(result.state).toBe('disabled'); + expect(result.data?.studentPercentage).toBeNull(); + expect(result.data?.submittedPercentage).toBeNull(); + expect(result.message).toBe(DISABLED_STATE.unavailableMessage); + }); + + it('keeps own progress but removes a malformed stale cohort percentage', () => { + const unsafeSummary: PeerProgressUnitSummary = { + ...toUnitSummary(STALE_STATE, 30), + submittedPercentage: 65, + }; + + const result = resolvePeerProgressUnitSummaryState(false, null, unsafeSummary); + + expect(result.state).toBe('stale'); + expect(result.data?.studentPercentage).toBe(30); + expect(result.data?.submittedPercentage).toBeNull(); + expect(result.message).toBe(STALE_STATE.unavailableMessage); + }); + + it('keeps a valid cohort percentage when the student percentage is missing', () => { + // A missing student figure says nothing about the cohort data we were + // given. The card renders the two panels independently, so the student + // panel falls to "Your progress is unavailable." on its own and the cohort + // panel keeps showing the number the API actually returned. + const result = resolvePeerProgressUnitSummaryState( + false, + null, + toUnitSummary(NORMAL_STATE, null), + ); + + expect(result.state).toBe('success'); + expect(result.data?.studentPercentage).toBeNull(); + expect(result.data?.submittedPercentage).toBe(NORMAL_STATE.submittedPercentage); + }); + + it('returns unavailable when the cohort percentage itself is missing', () => { + const result = resolvePeerProgressUnitSummaryState(false, null, { + ...toUnitSummary(NORMAL_STATE, 30), + submittedPercentage: null, + }); + + expect(result.state).toBe('unavailable'); + expect(result.data?.submittedPercentage).toBeNull(); + }); + + it('transitions from loading to success without carrying old data', () => { + const loadingResult = resolvePeerProgressUnitSummaryState(true, null, null); + + expect(loadingResult.data).toBeNull(); + + const summary = toUnitSummary(NORMAL_STATE, 30); + const successResult = resolvePeerProgressUnitSummaryState(false, null, summary); + + expect(successResult.state).toBe('success'); + expect(successResult.data).toEqual(summary); + }); +}); diff --git a/src/app/api/models/peer-progress-unit-summary-state.ts b/src/app/api/models/peer-progress-unit-summary-state.ts new file mode 100644 index 0000000000..53887c72f9 --- /dev/null +++ b/src/app/api/models/peer-progress-unit-summary-state.ts @@ -0,0 +1,104 @@ +import {PeerProgressUiState} from './peer-progress-indicator-state'; +import {PeerProgressUnitSummary} from './peer-progress-unit-summary'; + +export interface PeerProgressUnitSummaryViewModel { + state: PeerProgressUiState; + data: PeerProgressUnitSummary | null; + message: string | null; +} + +const GENERIC_ERROR_MESSAGE = 'Could not load peer progress. Please try again.'; + +function withoutCohortPercentage(data: PeerProgressUnitSummary): PeerProgressUnitSummary { + return { + ...data, + submittedPercentage: null, + }; +} + +function withoutAnyPercentage(data: PeerProgressUnitSummary): PeerProgressUnitSummary { + return { + ...data, + studentPercentage: null, + submittedPercentage: null, + }; +} + +/** + * Converts unit-summary data into the shared PPI UI states. + * + * Privacy rule: + * Hidden, unavailable and stale states must never retain an anonymous cohort + * percentage, even when a malformed response contains one. + * + * Accuracy rule: + * Disabled, loading and error states must not retain percentages that could be + * mistaken for current information. + */ +export function resolvePeerProgressUnitSummaryState( + loading: boolean, + error: unknown | null, + data: PeerProgressUnitSummary | null, +): PeerProgressUnitSummaryViewModel { + if (loading) { + return {state: 'loading', data: null, message: null}; + } + + if (error || !data) { + return { + state: 'error', + data: null, + message: GENERIC_ERROR_MESSAGE, + }; + } + + if (!data.isFeatureEnabled) { + return { + state: 'disabled', + data: withoutAnyPercentage(data), + message: data.unavailableMessage, + }; + } + + if (data.isSuppressed) { + return { + state: 'hidden', + data: withoutCohortPercentage(data), + message: data.unavailableMessage, + }; + } + + if (data.isStale) { + return { + state: 'stale', + data: withoutCohortPercentage(data), + message: data.unavailableMessage, + }; + } + + // Only a missing COHORT figure makes the cohort panel unavailable. A missing + // student figure says nothing about the cohort data we were given, and the + // card's own template already handles it -- discarding a valid cohort + // percentage here would make the card state something untrue about it. + if (data.submittedPercentage === null) { + return { + state: 'unavailable', + data: withoutCohortPercentage(data), + message: data.unavailableMessage, + }; + } + + if (data.submittedPercentage === 0) { + return { + state: 'no-data', + data, + message: null, + }; + } + + return { + state: 'success', + data, + message: null, + }; +} diff --git a/src/app/api/models/peer-progress-unit-summary.ts b/src/app/api/models/peer-progress-unit-summary.ts new file mode 100644 index 0000000000..8e408c8de3 --- /dev/null +++ b/src/app/api/models/peer-progress-unit-summary.ts @@ -0,0 +1,28 @@ +import {PeerProgressIndicator} from './peer-progress-indicator'; + +/** + * Unit-level PPI proof-of-concept model for PPI-F02. + * + * This model places the authenticated student's own unit progress beside an + * anonymous cohort aggregate. It is a mock-backed frontend model and does not + * represent a live unit-level API response. + * + * `studentPercentage` is the authenticated student's own unit progress. It may + * remain available when the anonymous cohort percentage is suppressed, + * unavailable, or stale. It is null when the student's own progress is + * unavailable or when the feature is disabled. + * + * `taskDefinitionId` is omitted because this summary is not scoped to one task. + */ +export interface PeerProgressUnitSummary extends Omit< + PeerProgressIndicator, + | 'taskDefinitionId' + | 'completedPercentage' + | 'distributionAvailable' + | 'statusDistribution' + | 'isUserEnabled' + | 'unavailableReason' + | 'distributionUnavailableReason' +> { + studentPercentage: number | null; +} diff --git a/src/app/api/models/peer-progress.ts b/src/app/api/models/peer-progress.ts new file mode 100644 index 0000000000..8e70cdd25b --- /dev/null +++ b/src/app/api/models/peer-progress.ts @@ -0,0 +1,33 @@ +export interface PeerMedianPoint { + date: string; + + /** + * Median proportion of target task weight still remaining across the cohort, + * from 0 to 1. This matches the scale used by the existing burndown series + * before those values are converted into percentages for display. + */ + remaining: number; +} + +/** + * A safe display state returned by the authorised backend or frontend adapter. + * + * The browser must not receive a raw cohort size and decide whether a cohort is + * large enough. That privacy decision belongs at the trusted data boundary. + */ +export type PeerProgressState = 'ready' | 'suppressed' | 'unavailable' | 'disabled'; + +export interface PeerProgressResponse { + project_id: number; + + /** The comparison applies to this selected target grade. */ + target_grade: number; + + /** Privacy-safe state; no raw cohort size is returned to the component. */ + state: PeerProgressState; + + /** + * Anonymous median points. This must be empty unless state is ready. + */ + median_burndown: PeerMedianPoint[]; +} diff --git a/src/app/api/services/mapping-fn.spec.ts b/src/app/api/services/mapping-fn.spec.ts new file mode 100644 index 0000000000..cf4334b5c6 --- /dev/null +++ b/src/app/api/services/mapping-fn.spec.ts @@ -0,0 +1,69 @@ +import {afterEach, beforeEach, describe, expect, it} from 'vitest'; +import {MappingFunctions} from './mapping-fn'; + +describe('MappingFunctions date-only mapping', () => { + let originalTimezone: string | undefined; + + beforeEach(() => { + originalTimezone = process.env.TZ; + process.env.TZ = 'America/New_York'; + }); + + afterEach(() => { + if (originalTimezone === undefined) { + delete process.env.TZ; + } else { + process.env.TZ = originalTimezone; + } + }); + + it('preserves an API date-only value when mapping to the end of the local day', () => { + const result = MappingFunctions.mapDateToEndOfDay({dueDate: '2026-09-15'}, 'dueDate', null); + + expect([ + result.getFullYear(), + result.getMonth(), + result.getDate(), + result.getHours(), + result.getMinutes(), + result.getSeconds(), + result.getMilliseconds(), + ]).toEqual([2026, 8, 15, 23, 59, 59, 999]); + }); + + it('preserves an API date-only value when mapping to the start of the local day', () => { + const result = MappingFunctions.mapDateToDay( + {targetDueDate: '2026-09-15'}, + 'targetDueDate', + null, + ); + + expect([ + result.getFullYear(), + result.getMonth(), + result.getDate(), + result.getHours(), + result.getMinutes(), + result.getSeconds(), + result.getMilliseconds(), + ]).toEqual([2026, 8, 15, 0, 0, 0, 0]); + }); + + it('keeps ordinary timestamp mapping instant-based', () => { + const result = MappingFunctions.mapDateToDay( + {completedAt: '2026-09-15T00:00:00.000Z'}, + 'completedAt', + null, + ); + + expect([ + result.getFullYear(), + result.getMonth(), + result.getDate(), + result.getHours(), + result.getMinutes(), + result.getSeconds(), + result.getMilliseconds(), + ]).toEqual([2026, 8, 14, 0, 0, 0, 0]); + }); +}); diff --git a/src/app/api/services/mapping-fn.ts b/src/app/api/services/mapping-fn.ts index 811dfca0cf..4be09e8a38 100644 --- a/src/app/api/services/mapping-fn.ts +++ b/src/app/api/services/mapping-fn.ts @@ -1,22 +1,40 @@ import moment from 'moment'; export class MappingFunctions { + private static mapDateValueToDay(value: unknown, preserveDatePrefix: boolean = false): Date { + if (typeof value === 'string') { + const dateOnlyPattern = preserveDatePrefix + ? /^(\d{4})-(\d{2})-(\d{2})(?:$|[T\s])/ + : /^(\d{4})-(\d{2})-(\d{2})$/; + const dateOnlyParts = dateOnlyPattern.exec(value); + if (dateOnlyParts) { + // Date parses bare ISO dates as UTC. Some API fields also encode known civil dates as + // midnight timestamps. Construct those values from their calendar components so they + // do not shift west of UTC; ordinary timestamp mappings remain instant-based. + return new Date( + Number(dateOnlyParts[1]), + Number(dateOnlyParts[2]) - 1, + Number(dateOnlyParts[3]), + ); + } + } + + const jsonDate = value instanceof Date ? value : new Date(value as string | number); + return new Date(jsonDate.getFullYear(), jsonDate.getMonth(), jsonDate.getDate()); + } + public static mapDateToEndOfDay(data, key, _entity, _params?) { - const jsonDate = new Date(data[key]); - return new Date( - jsonDate.getFullYear(), - jsonDate.getMonth(), - jsonDate.getDate(), - 23, // all dates map to end of day - 59, - 59, - 999, - ); + const result = MappingFunctions.mapDateValueToDay(data[key]); + result.setHours(23, 59, 59, 999); // all dates map to end of day + return result; } public static mapDateToDay(data, key: string, _entity, _params?) { - const jsonDate = new Date(data[key]); - return new Date(jsonDate.getFullYear(), jsonDate.getMonth(), jsonDate.getDate()); + return MappingFunctions.mapDateValueToDay(data[key]); + } + + public static mapCivilDateToDay(data, key: string, _entity, _params?) { + return MappingFunctions.mapDateValueToDay(data[key], true); } public static mapDate(data, key: string, _entity, _params?) { diff --git a/src/app/api/services/peer-progress-indicator.service.ts b/src/app/api/services/peer-progress-indicator.service.ts new file mode 100644 index 0000000000..2333533928 --- /dev/null +++ b/src/app/api/services/peer-progress-indicator.service.ts @@ -0,0 +1,188 @@ +import {HttpClient} from '@angular/common/http'; +import {Injectable} from '@angular/core'; +import {Observable, map, of} from 'rxjs'; +import API_URL from 'src/app/config/constants/apiUrl'; +import {DemoModeStore} from 'src/app/demo/demo-mode.store'; +import { + DemoPeerProgressState, + createDemoUnitSummary, + createMaskedPeerProgressIndicator, +} from 'src/app/demo/fixtures/peer-progress-demo.fixtures'; +import { + PeerProgressDistributionUnavailableReason, + PeerProgressIndicator, + PeerProgressUnavailableReason, +} from '../models/peer-progress-indicator'; +import {PeerProgressUnitSummary} from '../models/peer-progress-unit-summary'; +import {TaskStatus, TaskStatusEnum} from '../models/task-status'; + +interface PeerProgressStatusDistributionResponse { + status: string; + percentage: number; +} + +interface PeerProgressIndicatorResponse { + task_definition_id: number; + unit_id: number; + target_grade: number | null; + submitted_percentage: number | null; + completed_percentage?: number | null; + distribution_available?: boolean; + status_distribution?: PeerProgressStatusDistributionResponse[] | null; + is_user_enabled?: boolean; + unavailable_reason?: string | null; + distribution_unavailable_reason?: string | null; + is_suppressed: boolean; + is_stale: boolean; + is_feature_enabled: boolean; + last_updated_at: string | null; + unavailable_message: string; +} + +@Injectable({ + providedIn: 'root', +}) +export class PeerProgressIndicatorService { + private readonly knownStatuses: Set = new Set(TaskStatus.STATUS_KEYS); + private readonly unavailableReasons: Set = new Set([ + 'user_disabled', + 'feature_disabled', + 'target_grade_unavailable', + 'snapshot_unavailable', + 'insufficient_cohort', + 'aggregation_incomplete', + 'stale', + ]); + private readonly distributionUnavailableReasons: Set = + new Set(['detailed_data_unavailable', 'privacy_protection', ...this.unavailableReasons]); + + constructor( + private httpClient: HttpClient, + private demoMode: DemoModeStore, + ) {} + + getIndicator(projectId: number, taskDefinitionId: number): Observable { + if (this.demoMode.shouldMaskApiData) { + return of(createMaskedPeerProgressIndicator(taskDefinitionId)); + } + + const url = `${API_URL}/projects/${projectId}/task_def_id/${taskDefinitionId}/peer_progress`; + + return this.httpClient.get(url).pipe( + map((response) => { + const isUserEnabled = response.is_user_enabled !== false; + const mayExposeCompact = + isUserEnabled && + response.is_feature_enabled && + !response.is_suppressed && + !response.is_stale; + const mayExposeDistribution = mayExposeCompact && response.distribution_available === true; + const statusDistribution = mayExposeDistribution + ? this.mapStatusDistribution(response.status_distribution) + : null; + const distributionAvailable = statusDistribution !== null; + + return { + taskDefinitionId: response.task_definition_id, + unitId: response.unit_id, + targetGrade: response.target_grade, + submittedPercentage: mayExposeCompact + ? this.mapCompactPercentage(response.submitted_percentage) + : null, + completedPercentage: mayExposeCompact + ? this.mapCompactPercentage(response.completed_percentage) + : null, + distributionAvailable, + statusDistribution: statusDistribution ?? [], + isUserEnabled, + isSuppressed: response.is_suppressed, + isStale: response.is_stale, + isFeatureEnabled: response.is_feature_enabled, + lastUpdatedAt: response.last_updated_at, + unavailableMessage: response.unavailable_message, + unavailableReason: this.mapUnavailableReason(response.unavailable_reason), + distributionUnavailableReason: this.mapDistributionUnavailableReason( + response.distribution_unavailable_reason, + ), + }; + }), + ); + } + + private mapUnavailableReason( + reason: string | null | undefined, + ): PeerProgressUnavailableReason | null { + return this.unavailableReasons.has(reason as PeerProgressUnavailableReason) + ? (reason as PeerProgressUnavailableReason) + : null; + } + + private mapDistributionUnavailableReason( + reason: string | null | undefined, + ): PeerProgressDistributionUnavailableReason | null { + return this.distributionUnavailableReasons.has( + reason as PeerProgressDistributionUnavailableReason, + ) + ? (reason as PeerProgressDistributionUnavailableReason) + : null; + } + + private mapStatusDistribution( + distribution: PeerProgressStatusDistributionResponse[] | null | undefined, + ): PeerProgressIndicator['statusDistribution'] | null { + if (!Array.isArray(distribution) || distribution.length !== this.knownStatuses.size) { + return null; + } + + const seen: Set = new Set(); + const result: PeerProgressIndicator['statusDistribution'] = []; + + for (const entry of distribution) { + const status = entry?.status as TaskStatusEnum; + const percentage = entry?.percentage; + + if ( + !this.knownStatuses.has(status) || + seen.has(status) || + typeof percentage !== 'number' || + !Number.isFinite(percentage) || + percentage < 0 || + percentage > 100 || + percentage % 10 !== 0 + ) { + return null; + } + + seen.add(status); + result.push({status, percentage}); + } + + return seen.size === this.knownStatuses.size ? result : null; + } + + private mapCompactPercentage(value: unknown): number | null { + return typeof value === 'number' && + Number.isFinite(value) && + value >= 0 && + value <= 100 && + value % 10 === 0 + ? value + : null; + } + + /** + * Builds a privacy-safe unit-summary fixture for PPI-F02 tests and demos. + * + * This is mock-only. It must not be treated as the live PPI-F01 production + * adapter, and the caller-supplied target grade must never become the design + * of a future live endpoint. + */ + getDemoUnitSummary( + unitId: number, + targetGrade: number, + studentPercentage: number | null, + state: DemoPeerProgressState, + ): Observable { + return of(createDemoUnitSummary(unitId, targetGrade, studentPercentage, state)); + } +} diff --git a/src/app/api/services/peer-progress.service.ts b/src/app/api/services/peer-progress.service.ts new file mode 100644 index 0000000000..2d4b84932f --- /dev/null +++ b/src/app/api/services/peer-progress.service.ts @@ -0,0 +1,56 @@ +import {Injectable} from '@angular/core'; +import {Observable, of} from 'rxjs'; +import { + DEMO_PEER_MEDIAN_STATE, + DEMO_WEEKLY_REMAINING, +} from 'src/app/demo/fixtures/peer-progress-demo.fixtures'; +import {PeerMedianPoint, PeerProgressResponse} from '../models/peer-progress'; +import {Project} from '../models/project'; +import {MappingFunctions} from './mapping-fn'; + +@Injectable({ + providedIn: 'root', +}) +export class PeerProgressService { + /** + * Temporary proof-of-concept state. + * + * The future authorised backend must decide whether the result is ready, + * suppressed, unavailable or disabled. It must not send the raw cohort size + * to the browser. + */ + public getCohortMedian( + project: Project, + targetGrade: number = project.targetGrade, + ): Observable { + const state = DEMO_PEER_MEDIAN_STATE; + + return of({ + project_id: project.id, + target_grade: targetGrade, + state, + median_burndown: state === 'ready' ? this.sampleProfile(project, DEMO_WEEKLY_REMAINING) : [], + }); + } + + private sampleProfile(project: Project, profile: readonly number[]): PeerMedianPoint[] { + const asOf = Date.now(); + const weeks = MappingFunctions.step( + project.unit.startDate.getTime(), + project.unit.endDate.getTime(), + MappingFunctions.weeksMs(1), + ); + + // Map against the whole unit before removing future points. Mapping only + // the elapsed weeks would incorrectly stretch the sample to 100% complete + // at today's date and make the demo look like the cohort has finished every + // task. + return weeks + .map((time, week) => ({ + date: new Date(time).toISOString(), + remaining: + profile[Math.round((week / Math.max(weeks.length - 1, 1)) * (profile.length - 1))], + })) + .filter((point) => new Date(point.date).getTime() <= asOf); + } +} diff --git a/src/app/api/services/spec/peer-progress-indicator.service.spec.ts b/src/app/api/services/spec/peer-progress-indicator.service.spec.ts new file mode 100644 index 0000000000..15d8b03515 --- /dev/null +++ b/src/app/api/services/spec/peer-progress-indicator.service.spec.ts @@ -0,0 +1,391 @@ +import {afterEach, beforeEach, describe, expect, it} from 'vitest'; +import {provideHttpClient, withInterceptorsFromDi, withXhr} from '@angular/common/http'; +import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing'; +import {TestBed} from '@angular/core/testing'; +import {PeerProgressIndicator} from 'src/app/api/models/peer-progress-indicator'; +import API_URL from 'src/app/config/constants/apiUrl'; +import {DemoModeStore} from 'src/app/demo/demo-mode.store'; +import { + DEMO_STATUS_DISTRIBUTION, + DISABLED_STATE, + NORMAL_STATE, + SUPPRESSED_STATE, +} from 'src/app/demo/fixtures/peer-progress-demo.fixtures'; +import {PeerProgressIndicatorService} from '../peer-progress-indicator.service'; + +describe('PeerProgressIndicatorService', () => { + let service: PeerProgressIndicatorService; + let httpMock: HttpTestingController; + let demoMode: {shouldMaskApiData: boolean}; + + beforeEach(() => { + demoMode = {shouldMaskApiData: false}; + TestBed.configureTestingModule({ + providers: [ + PeerProgressIndicatorService, + {provide: DemoModeStore, useValue: demoMode}, + provideHttpClient(withXhr(), withInterceptorsFromDi()), + provideHttpClientTesting(), + ], + }); + + service = TestBed.inject(PeerProgressIndicatorService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify({ignoreCancelled: true}); + }); + + it('requests the authorised project/task route without client-supplied cohort parameters', () => { + service.getIndicator(7, 99).subscribe(); + + const request = httpMock.expectOne(`${API_URL}/projects/7/task_def_id/99/peer_progress`); + + expect(request.request.method).toBe('GET'); + expect(request.request.params.keys()).toEqual([]); + + request.flush({ + task_definition_id: 99, + unit_id: 1, + target_grade: 2, + submitted_percentage: 40, + is_suppressed: false, + is_stale: false, + is_feature_enabled: true, + last_updated_at: '2026-08-23T00:00:00Z', + unavailable_message: '', + }); + }); + + it('maps the complete snake-case response and preserves nullable fields', () => { + let result: PeerProgressIndicator | undefined; + + service.getIndicator(7, 99).subscribe((value) => { + result = value; + }); + + httpMock.expectOne(`${API_URL}/projects/7/task_def_id/99/peer_progress`).flush({ + task_definition_id: 99, + unit_id: 1, + target_grade: null, + submitted_percentage: null, + is_suppressed: false, + is_stale: false, + is_feature_enabled: true, + last_updated_at: null, + unavailable_message: 'Peer progress is currently unavailable.', + }); + + expect(result).toEqual({ + taskDefinitionId: 99, + unitId: 1, + targetGrade: null, + submittedPercentage: null, + completedPercentage: null, + distributionAvailable: false, + statusDistribution: [], + isUserEnabled: true, + isSuppressed: false, + isStale: false, + isFeatureEnabled: true, + lastUpdatedAt: null, + unavailableMessage: 'Peer progress is currently unavailable.', + unavailableReason: null, + distributionUnavailableReason: null, + }); + }); + + it('maps the full quantised status distribution and compact completion metric', () => { + let result: PeerProgressIndicator | undefined; + + service.getIndicator(7, 99).subscribe((value) => { + result = value; + }); + + httpMock.expectOne(`${API_URL}/projects/7/task_def_id/99/peer_progress`).flush({ + task_definition_id: 99, + unit_id: 1, + target_grade: 2, + submitted_percentage: 60, + completed_percentage: 10, + distribution_available: true, + status_distribution: DEMO_STATUS_DISTRIBUTION, + is_user_enabled: true, + is_suppressed: false, + is_stale: false, + is_feature_enabled: true, + last_updated_at: '2026-08-23T00:00:00Z', + unavailable_message: '', + unavailable_reason: null, + distribution_unavailable_reason: null, + }); + + expect(result).toMatchObject({ + submittedPercentage: 60, + completedPercentage: 10, + distributionAvailable: true, + statusDistribution: DEMO_STATUS_DISTRIBUTION, + isUserEnabled: true, + }); + }); + + it.each([ + ['a missing canonical status', DEMO_STATUS_DISTRIBUTION.slice(0, -1)], + ['a duplicate status', [...DEMO_STATUS_DISTRIBUTION.slice(0, -1), DEMO_STATUS_DISTRIBUTION[0]]], + [ + 'an unknown status', + [...DEMO_STATUS_DISTRIBUTION.slice(0, -1), {status: 'unknown_status', percentage: 0}], + ], + [ + 'a non-quantised percentage', + DEMO_STATUS_DISTRIBUTION.map((entry, index) => + index === 0 ? {...entry, percentage: 15} : entry, + ), + ], + ])('fails the entire detailed vector closed for %s', (_label, statusDistribution) => { + let result: PeerProgressIndicator | undefined; + + service.getIndicator(7, 99).subscribe((value) => { + result = value; + }); + + httpMock.expectOne(`${API_URL}/projects/7/task_def_id/99/peer_progress`).flush({ + task_definition_id: 99, + unit_id: 1, + target_grade: 2, + submitted_percentage: 60, + completed_percentage: 10, + distribution_available: true, + status_distribution: statusDistribution, + is_user_enabled: true, + is_suppressed: false, + is_stale: false, + is_feature_enabled: true, + last_updated_at: '2026-08-23T00:00:00Z', + unavailable_message: '', + distribution_unavailable_reason: 'detailed_data_unavailable', + }); + + expect(result?.distributionAvailable).toBe(false); + expect(result?.statusDistribution).toEqual([]); + expect(result?.completedPercentage).toBe(10); + }); + + it('scrubs every compact and detailed value when the server reports the user preference off', () => { + let result: PeerProgressIndicator | undefined; + + service.getIndicator(7, 99).subscribe((value) => { + result = value; + }); + + httpMock.expectOne(`${API_URL}/projects/7/task_def_id/99/peer_progress`).flush({ + task_definition_id: 99, + unit_id: 1, + target_grade: 2, + submitted_percentage: 60, + completed_percentage: 10, + distribution_available: true, + status_distribution: DEMO_STATUS_DISTRIBUTION, + is_user_enabled: false, + is_suppressed: false, + is_stale: false, + is_feature_enabled: true, + last_updated_at: '2026-08-23T00:00:00Z', + unavailable_message: 'server-provided preference detail', + unavailable_reason: 'user_disabled', + distribution_unavailable_reason: 'user_disabled', + }); + + expect(result).toMatchObject({ + submittedPercentage: null, + completedPercentage: null, + distributionAvailable: false, + statusDistribution: [], + isUserEnabled: false, + unavailableReason: 'user_disabled', + }); + }); + + it('scrubs compact and detailed values from a suppressed response even if the payload is malformed', () => { + let result: PeerProgressIndicator | undefined; + + service.getIndicator(7, 99).subscribe((value) => { + result = value; + }); + + httpMock.expectOne(`${API_URL}/projects/7/task_def_id/99/peer_progress`).flush({ + task_definition_id: 99, + unit_id: 1, + target_grade: 2, + submitted_percentage: 60, + completed_percentage: 10, + distribution_available: true, + status_distribution: DEMO_STATUS_DISTRIBUTION, + is_user_enabled: true, + is_suppressed: true, + is_stale: false, + is_feature_enabled: true, + last_updated_at: '2026-08-23T00:00:00Z', + unavailable_message: 'Not enough students to show progress.', + unavailable_reason: 'insufficient_cohort', + distribution_unavailable_reason: 'insufficient_cohort', + }); + + expect(result).toMatchObject({ + submittedPercentage: null, + completedPercentage: null, + distributionAvailable: false, + statusDistribution: [], + isSuppressed: true, + }); + }); + + it('does not retain unrecognised reason strings from the API', () => { + let result: PeerProgressIndicator | undefined; + + service.getIndicator(7, 99).subscribe((value) => { + result = value; + }); + + httpMock.expectOne(`${API_URL}/projects/7/task_def_id/99/peer_progress`).flush({ + task_definition_id: 99, + unit_id: 1, + target_grade: 2, + submitted_percentage: 60, + completed_percentage: 10, + distribution_available: false, + status_distribution: null, + is_user_enabled: true, + is_suppressed: false, + is_stale: false, + is_feature_enabled: true, + last_updated_at: '2026-08-23T00:00:00Z', + unavailable_message: '', + unavailable_reason: 'sensitive-internal-reason', + distribution_unavailable_reason: 'sensitive-internal-detail', + }); + + expect(result?.unavailableReason).toBeNull(); + expect(result?.distributionUnavailableReason).toBeNull(); + }); + + it.each([ + ['not finite', Number.NaN, Number.POSITIVE_INFINITY], + ['negative', -10, -20], + ['over 100', 110, 120], + ['not quantised', 15, 25], + ])( + 'fails malformed compact percentages closed when they are %s', + (_label, submitted, completed) => { + let result: PeerProgressIndicator | undefined; + + service.getIndicator(7, 99).subscribe((value) => { + result = value; + }); + + httpMock.expectOne(`${API_URL}/projects/7/task_def_id/99/peer_progress`).flush({ + task_definition_id: 99, + unit_id: 1, + target_grade: 2, + submitted_percentage: submitted, + completed_percentage: completed, + distribution_available: false, + status_distribution: null, + is_user_enabled: true, + is_suppressed: false, + is_stale: false, + is_feature_enabled: true, + last_updated_at: '2026-08-23T00:00:00Z', + unavailable_message: '', + }); + + expect(result?.submittedPercentage).toBeNull(); + expect(result?.completedPercentage).toBeNull(); + }, + ); + + it('cancels the live HTTP request when the caller unsubscribes', () => { + const subscription = service.getIndicator(7, 99).subscribe(); + const request = httpMock.expectOne(`${API_URL}/projects/7/task_def_id/99/peer_progress`); + + subscription.unsubscribe(); + + expect(request.cancelled).toBe(true); + }); + + it('masks task peer comparison without calling the API while local demo mode is off', () => { + demoMode.shouldMaskApiData = true; + let result: PeerProgressIndicator | undefined; + + service.getIndicator(7, 99).subscribe((value) => { + result = value; + }); + + httpMock.expectNone(`${API_URL}/projects/7/task_def_id/99/peer_progress`); + expect(result).toMatchObject({ + taskDefinitionId: 99, + submittedPercentage: null, + isFeatureEnabled: false, + unavailableMessage: 'Enable demo mode to show live peer comparison data.', + }); + }); + + it('keeps the genuine API path when masking is unavailable in production', () => { + demoMode.shouldMaskApiData = false; + service.getIndicator(7, 99).subscribe(); + + httpMock.expectOne(`${API_URL}/projects/7/task_def_id/99/peer_progress`).flush({ + task_definition_id: 99, + unit_id: 1, + target_grade: 2, + submitted_percentage: 40, + is_suppressed: false, + is_stale: false, + is_feature_enabled: true, + last_updated_at: '2026-08-23T00:00:00Z', + unavailable_message: '', + }); + }); + + describe('getDemoUnitSummary', () => { + it('should keep the student and cohort percentages separate for a normal response', () => { + service.getDemoUnitSummary(10, 2, 30, 'normal').subscribe((result) => { + expect(result.studentPercentage).toBe(30); + expect(result.submittedPercentage).toBe(NORMAL_STATE.submittedPercentage); + expect(result.isSuppressed).toBe(NORMAL_STATE.isSuppressed); + + expect(result.unitId).toBe(10); + expect(result.targetGrade).toBe(2); + }); + }); + + it('should not expose a task definition id -- a unit summary is not task-scoped', () => { + service.getDemoUnitSummary(10, 2, 30, 'normal').subscribe((result) => { + expect((result as unknown as {taskDefinitionId?: number}).taskDefinitionId).toBeUndefined(); + }); + }); + + it('should return suppressed state with the safe message and no cohort percentage', () => { + service.getDemoUnitSummary(30, 0, 30, 'suppressed').subscribe((result) => { + expect(result.submittedPercentage).toBe(SUPPRESSED_STATE.submittedPercentage); + expect(result.isSuppressed).toBe(true); + expect(result.unavailableMessage).toBe(SUPPRESSED_STATE.unavailableMessage); + expect(result.studentPercentage).toBe(30); + }); + }); + + it('should return disabled state when the unit has PPI turned off', () => { + service.getDemoUnitSummary(60, 1, 30, 'disabled').subscribe((result) => { + expect(result.isFeatureEnabled).toBe(false); + expect(result.unavailableMessage).toBe(DISABLED_STATE.unavailableMessage); + }); + }); + + it('should preserve a null studentPercentage rather than substituting a default', () => { + service.getDemoUnitSummary(10, 2, null, 'normal').subscribe((result) => { + expect(result.studentPercentage).toBeNull(); + }); + }); + }); +}); diff --git a/src/app/api/services/spec/peer-progress.service.spec.ts b/src/app/api/services/spec/peer-progress.service.spec.ts new file mode 100644 index 0000000000..c49b353dc7 --- /dev/null +++ b/src/app/api/services/spec/peer-progress.service.spec.ts @@ -0,0 +1,66 @@ +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {firstValueFrom} from 'rxjs'; +import {Project} from '../../models/project'; +import {PeerProgressService} from '../peer-progress.service'; + +describe('PeerProgressService', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('returns a safe demo state without exposing raw cohort size', async () => { + const project = { + id: 42, + targetGrade: 2, + unit: { + startDate: new Date(2026, 6, 1), + endDate: new Date(2026, 7, 31), + }, + } as unknown as Project; + + const service = new PeerProgressService(); + + const response = await firstValueFrom(service.getCohortMedian(project, 3)); + + expect(response).toMatchObject({ + project_id: 42, + target_grade: 3, + state: 'ready', + }); + + expect(Object.keys(response)).not.toContain('cohort_size'); + + expect(response.median_burndown.length).toBeGreaterThan(0); + + expect( + response.median_burndown.every((point) => point.remaining >= 0 && point.remaining <= 1), + ).toBe(true); + }); + + it('shows the demo curve only through today without remapping its future values', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-23T00:00:00.000Z')); + + const project = { + id: 42, + targetGrade: 0, + unit: { + startDate: new Date('2026-07-12T00:00:00.000Z'), + endDate: new Date('2026-10-11T00:00:00.000Z'), + }, + } as unknown as Project; + + const response = await firstValueFrom(new PeerProgressService().getCohortMedian(project)); + const lastPoint = response.median_burndown.at(-1); + + expect(lastPoint).toEqual({ + date: '2026-08-23T00:00:00.000Z', + remaining: 0.58, + }); + expect(response.median_burndown.every((point) => new Date(point.date) <= new Date())).toBe( + true, + ); + expect(Math.round((1 - lastPoint!.remaining) * 100)).toBe(42); + expect(lastPoint?.remaining).not.toBe(0); + }); +}); diff --git a/src/app/api/services/spec/task-recommendation.service.spec.ts b/src/app/api/services/spec/task-recommendation.service.spec.ts new file mode 100644 index 0000000000..cf1d4dde8d --- /dev/null +++ b/src/app/api/services/spec/task-recommendation.service.spec.ts @@ -0,0 +1,71 @@ +import {provideHttpClient, withInterceptorsFromDi, withXhr} from '@angular/common/http'; +import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing'; +import {TestBed} from '@angular/core/testing'; +import API_URL from 'src/app/config/constants/apiUrl'; +import {TaskRecommendation, TaskRecommendationService} from '../task-recommendation.service'; + +describe('TaskRecommendationService', () => { + let httpTestingController: HttpTestingController; + let service: TaskRecommendationService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + TaskRecommendationService, + provideHttpClient(withXhr(), withInterceptorsFromDi()), + provideHttpClientTesting(), + ], + }); + + httpTestingController = TestBed.inject(HttpTestingController); + service = TestBed.inject(TaskRecommendationService); + }); + + afterEach(() => { + httpTestingController.verify(); + }); + + it('loads and combines every recommendation page', () => { + let recommendations: TaskRecommendation[] | undefined; + + service.getAll().subscribe((result) => { + recommendations = result; + }); + + const firstRequest = httpTestingController.expectOne( + (request) => + request.url === `${API_URL}/tasks/recommended` && + request.params.get('page') === '1' && + request.params.get('per_page') === '50', + ); + firstRequest.flush({ + data: [makeRecommendation(1, 80)], + meta: {page: 1, per_page: 50, total_count: 2, total_pages: 2}, + }); + + const secondRequest = httpTestingController.expectOne( + (request) => + request.url === `${API_URL}/tasks/recommended` && + request.params.get('page') === '2' && + request.params.get('per_page') === '50', + ); + secondRequest.flush({ + data: [makeRecommendation(2, 60)], + meta: {page: 2, per_page: 50, total_count: 2, total_pages: 2}, + }); + + expect(recommendations).toEqual([makeRecommendation(1, 80), makeRecommendation(2, 60)]); + }); + + const makeRecommendation = ( + taskDefinitionId: number, + priorityScore: number, + ): TaskRecommendation => ({ + task_id: null, + task_definition_id: taskDefinitionId, + task_name: `Task ${taskDefinitionId}`, + project_id: 10, + unit_id: 20, + priority_score: priorityScore, + }); +}); diff --git a/src/app/api/services/task-recommendation.service.ts b/src/app/api/services/task-recommendation.service.ts new file mode 100644 index 0000000000..475a990f66 --- /dev/null +++ b/src/app/api/services/task-recommendation.service.ts @@ -0,0 +1,53 @@ +import {HttpClient} from '@angular/common/http'; +import {Injectable} from '@angular/core'; +import {EMPTY, Observable, expand, reduce} from 'rxjs'; +import API_URL from 'src/app/config/constants/apiUrl'; + +export type TaskRecommendation = { + task_id: number | null; + task_definition_id: number; + task_name: string; + project_id: number; + unit_id: number; + priority_score: number; +}; + +export type TaskRecommendationPage = { + data: TaskRecommendation[]; + meta: { + page: number; + per_page: number; + total_count: number; + total_pages: number; + }; +}; + +@Injectable({providedIn: 'root'}) +export class TaskRecommendationService { + private static readonly PAGE_SIZE = 50; + + constructor(private httpClient: HttpClient) {} + + getAll(): Observable { + return this.getPage(1).pipe( + expand((response) => + response.meta.page < response.meta.total_pages + ? this.getPage(response.meta.page + 1) + : EMPTY, + ), + reduce( + (recommendations, response) => recommendations.concat(response.data), + [] as TaskRecommendation[], + ), + ); + } + + private getPage(page: number): Observable { + return this.httpClient.get(`${API_URL}/tasks/recommended`, { + params: { + page, + per_page: TaskRecommendationService.PAGE_SIZE, + }, + }); + } +} diff --git a/src/app/common/project-card/project-card.component.html b/src/app/common/project-card/project-card.component.html new file mode 100644 index 0000000000..5300e34f54 --- /dev/null +++ b/src/app/common/project-card/project-card.component.html @@ -0,0 +1,34 @@ +@if (project) { +
+
+
+ @if (project.unitCode) { +

{{ project.unitCode }}

+ } +

{{ project.title }}

+
+ + + Project status: {{ project.status }} + +
+ +

+ {{ project.progressSummary }} +

+ + @if (project.description) { +

+ {{ project.description }} +

+ } + + + Open project + +
+} diff --git a/src/app/common/project-card/project-card.component.scss b/src/app/common/project-card/project-card.component.scss new file mode 100644 index 0000000000..e90278716d --- /dev/null +++ b/src/app/common/project-card/project-card.component.scss @@ -0,0 +1,77 @@ +.project-card { + border: 1px solid #d6d6d6; + border-radius: 12px; + padding: 1rem; + background: #ffffff; + display: flex; + flex-direction: column; + gap: 0.75rem; + max-width: 420px; +} + +.project-card__header { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: flex-start; +} + +.project-card__unit { + margin: 0 0 0.25rem; + font-size: 0.8rem; + font-weight: 600; + color: #555555; +} + +.project-card__title { + margin: 0; + font-size: 1.1rem; + line-height: 1.3; +} + +.project-card__status { + border-radius: 999px; + padding: 0.25rem 0.6rem; + font-size: 0.8rem; + font-weight: 600; + background: #eeeeee; + white-space: nowrap; +} + +.project-card__status-label { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.project-card__summary, +.project-card__description { + margin: 0; + line-height: 1.5; +} + +.project-card__link { + align-self: flex-start; + font-weight: 600; + text-decoration: underline; +} + +@media (max-width: 520px) { + .project-card { + max-width: 100%; + } + + .project-card__header { + flex-direction: column; + } + + .project-card__status { + align-self: flex-start; + } +} diff --git a/src/app/common/project-card/project-card.component.spec.ts b/src/app/common/project-card/project-card.component.spec.ts new file mode 100644 index 0000000000..5d8a798c81 --- /dev/null +++ b/src/app/common/project-card/project-card.component.spec.ts @@ -0,0 +1,74 @@ +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {ProjectCardComponent, ProjectCardData} from './project-card.component'; + +describe('ProjectCardComponent', () => { + let component: ProjectCardComponent; + let fixture: ComponentFixture; + + const sampleProject: ProjectCardData = { + title: 'Cross-Project Dashboard', + unitCode: 'CPD-F02', + status: 'In progress', + progressSummary: 'Reusable dashboard project-card component is being prepared.', + description: 'This card summarises one project without hardcoding the dashboard data.', + destinationUrl: '/projects/cpd-f02', + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ProjectCardComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(ProjectCardComponent); + component = fixture.componentInstance; + component.project = sampleProject; + fixture.detectChanges(); + }); + + it('should create the project card component', () => { + expect(component).toBeTruthy(); + }); + + it('should display the supplied project details', () => { + const text = fixture.nativeElement.textContent; + + expect(text).toContain('Cross-Project Dashboard'); + expect(text).toContain('CPD-F02'); + expect(text).toContain('In progress'); + expect(text).toContain('Reusable dashboard project-card component is being prepared.'); + }); + + it('should provide an accessible project link', () => { + const link: HTMLAnchorElement = fixture.nativeElement.querySelector('a'); + + expect(link).toBeTruthy(); + expect(link.getAttribute('href')).toBe('/projects/cpd-f02'); + expect(link.getAttribute('aria-label')).toBe('Open Cross-Project Dashboard project details'); + }); + + it('should expose the project status to assistive technology', () => { + const status: HTMLSpanElement = fixture.nativeElement.querySelector('.project-card__status'); + + expect(status.textContent.replace(/\s+/g, ' ').trim()).toBe('Project status: In progress'); + }); + + it('should handle missing optional fields', () => { + fixture.componentRef.setInput('project', { + title: 'Sample Project', + status: 'Completed', + progressSummary: 'Core details are still displayed.', + destinationUrl: '/projects/sample', + }); + + fixture.detectChanges(); + + const text = fixture.nativeElement.textContent; + + expect(text).toContain('Sample Project'); + expect(text).toContain('Completed'); + expect(text).toContain('Core details are still displayed.'); + + const link: HTMLAnchorElement = fixture.nativeElement.querySelector('a'); + expect(link.getAttribute('aria-label')).toBe('Open Sample Project project details'); + }); +}); diff --git a/src/app/common/project-card/project-card.component.ts b/src/app/common/project-card/project-card.component.ts new file mode 100644 index 0000000000..89e0704e66 --- /dev/null +++ b/src/app/common/project-card/project-card.component.ts @@ -0,0 +1,20 @@ +import {Component, Input} from '@angular/core'; + +export interface ProjectCardData { + title: string; + status: string; + progressSummary: string; + destinationUrl: string; + unitCode?: string; + description?: string; +} + +@Component({ + selector: 'project-card', + standalone: false, + templateUrl: './project-card.component.html', + styleUrls: ['./project-card.component.scss'], +}) +export class ProjectCardComponent { + @Input() project!: ProjectCardData; +} diff --git a/src/app/common/services/ppi-progress-calculation.md b/src/app/common/services/ppi-progress-calculation.md new file mode 100644 index 0000000000..191e0ee8ec --- /dev/null +++ b/src/app/common/services/ppi-progress-calculation.md @@ -0,0 +1,15 @@ +# PPI Progress Calculation Rules + +Completion percentage is calculated as: + +completed available tasks / total available tasks ร— 100 + +Results are rounded to the nearest whole number using `Math.round()`. + +If there are no available tasks, the result is treated as unavailable rather than dividing by zero. + +If cohort data is missing or suppressed, the student-to-cohort comparison is also treated as unavailable. + +The current comparison value is calculated as: + +student completion percentage - cohort completion percentage diff --git a/src/app/common/services/ppi-progress-calculation.service.spec.ts b/src/app/common/services/ppi-progress-calculation.service.spec.ts new file mode 100644 index 0000000000..cb251dd084 --- /dev/null +++ b/src/app/common/services/ppi-progress-calculation.service.spec.ts @@ -0,0 +1,51 @@ +import {describe, expect, it} from 'vitest'; +import { + calculateCompletionPercentage, + calculateProgressComparison, +} from './ppi-progress-calculation.service'; + +describe('PPI progress calculations', () => { + describe('calculateCompletionPercentage', () => { + it('returns 0 when no tasks are completed', () => { + expect(calculateCompletionPercentage(0, 20)).toBe(0); + }); + + it('calculates partial completion', () => { + expect(calculateCompletionPercentage(5, 20)).toBe(25); + }); + + it('returns 100 when all tasks are completed', () => { + expect(calculateCompletionPercentage(20, 20)).toBe(100); + }); + + it('returns null when no tasks are available', () => { + expect(calculateCompletionPercentage(0, 0)).toBeNull(); + }); + + it('returns null when completed tasks exceed available tasks', () => { + expect(calculateCompletionPercentage(25, 20)).toBeNull(); + }); + + it('rounds to the nearest whole percentage', () => { + expect(calculateCompletionPercentage(2, 3)).toBe(67); + }); + }); + + describe('calculateProgressComparison', () => { + it('returns a positive value when student is ahead', () => { + expect(calculateProgressComparison(60, 50)).toBe(10); + }); + + it('returns a negative value when student is behind', () => { + expect(calculateProgressComparison(40, 50)).toBe(-10); + }); + + it('returns zero when student matches the cohort', () => { + expect(calculateProgressComparison(50, 50)).toBe(0); + }); + + it('returns null when cohort data is unavailable', () => { + expect(calculateProgressComparison(50, null)).toBeNull(); + }); + }); +}); diff --git a/src/app/common/services/ppi-progress-calculation.service.ts b/src/app/common/services/ppi-progress-calculation.service.ts new file mode 100644 index 0000000000..ff0bb0ec80 --- /dev/null +++ b/src/app/common/services/ppi-progress-calculation.service.ts @@ -0,0 +1,32 @@ +export function calculateCompletionPercentage(completed: number, available: number): number | null { + if ( + !Number.isFinite(completed) || + !Number.isFinite(available) || + completed < 0 || + available <= 0 || + completed > available + ) { + return null; + } + + return Math.round((completed / available) * 100); +} + +export function calculateProgressComparison( + studentPercentage: number, + cohortPercentage: number | null, +): number | null { + if ( + cohortPercentage === null || + !Number.isFinite(studentPercentage) || + !Number.isFinite(cohortPercentage) || + studentPercentage < 0 || + studentPercentage > 100 || + cohortPercentage < 0 || + cohortPercentage > 100 + ) { + return null; + } + + return Math.round(studentPercentage - cohortPercentage); +} diff --git a/src/app/dashboard/f-cross-dashboard.component.html b/src/app/dashboard/f-cross-dashboard.component.html new file mode 100644 index 0000000000..c6882ed83c --- /dev/null +++ b/src/app/dashboard/f-cross-dashboard.component.html @@ -0,0 +1,489 @@ +
+ +
+

{{ resultCountLabel }}

+ + @if (unitScope !== 'active') { + + } + +
+
+ @if (loadingActiveUnits || activeUnitsLoadError) { + + } + @if (loadingRecommendations || recommendationsLoadError) { + + } +
+
+ @for (unit of displayedUnits; track unit.projectId) { +
+
+

+ +

+ + + + Open {{ unit.code }} dashboard + +
+ + +
+ + + @for (mode of sortOptions; track mode) { + + } + + + + + @for (mode of filterOptions; track mode) { + + } + +
+
+
+ +
+ @if (unit.gradeSummaries.length > 0) { +
+

Grade task completion

+
+ @for (summary of unit.gradeSummaries; track summary.targetGrade) { + + @if (summary.total === 0) { + {{ summary.label }}: 0 tasks + } @else { + {{ summary.label }}: {{ summary.completed }} of {{ summary.total }} complete + ({{ summary.percentage }}%) + } + + } +
+
+ } + +
+ @for (task of unit.tasks; track task.abbreviation) { +
+ +
+ } + + @if (unit.tasks.length === 0) { +
+ @if (hasActiveTaskCriteria(unit.projectId)) { +

No tasks match the current search and filters.

+ } @else { +

No tasks are available.

+ } +
+ } +
+
+
+ } + + @if ( + unitScope === 'active' && + !loadingActiveUnits && + !activeUnitsLoadError && + activeUnits.length === 0 && + !hasGlobalTaskCriteria + ) { + + } + + @if ( + hasGlobalTaskCriteria && + displayedUnits.length === 0 && + !loadingPreviousUnits && + !previousUnitsLoadError + ) { +
+

No tasks match the current global search and filters.

+
+ } + + @if (unitScope !== 'active' && loadingPreviousUnits) { + + } + + @if (unitScope !== 'active' && previousUnitsLoadError) { + + } + + @if ( + unitScope !== 'active' && + previousUnitsLoaded && + !loadingPreviousUnits && + previousUnits.length === 0 && + !hasGlobalTaskCriteria + ) { +
+
+

Previous units

+
+
+

No previous units are available.

+
+
+ } +
+
+ + +
+

{{ title }}

+

{{ message }}

+
+
diff --git a/src/app/dashboard/f-cross-dashboard.component.scss b/src/app/dashboard/f-cross-dashboard.component.scss new file mode 100644 index 0000000000..0557513be0 --- /dev/null +++ b/src/app/dashboard/f-cross-dashboard.component.scss @@ -0,0 +1,72 @@ +.dashboard-global-advanced-filters { + display: none !important; +} + +.dashboard-global-advanced-filters.is-mobile-open { + display: grid !important; + grid-template-columns: minmax(0, 1fr); + gap: 0.75rem; +} + +.dashboard-project-controls, +.dashboard-project-content { + display: none !important; +} + +.dashboard-project-controls.is-mobile-open, +.dashboard-project-content.is-mobile-open { + display: flex !important; +} + +.dashboard-project-controls { + width: 100%; + flex-direction: column; + gap: 0.75rem; + margin-top: 0.75rem; +} + +@media (min-width: 640px) { + .dashboard-global-advanced-filters, + .dashboard-global-advanced-filters.is-mobile-open { + display: contents !important; + } + + .dashboard-project-controls, + .dashboard-project-content { + display: flex !important; + } + + .dashboard-project-controls { + width: 100%; + flex-direction: row; + flex-wrap: wrap; + align-items: center; + gap: 1rem; + margin-top: 0.75rem; + } + + .dashboard-project-content { + flex: 1 1 auto; + } +} +.dashboard-units-layout { + grid-template-columns: repeat(auto-fit, minmax(min(100%, 30rem), 1fr)); + align-items: start; +} + +.dashboard-panel-state { + min-height: 7rem; +} + +.dashboard-compact .dashboard-card-header { + padding: 0.75rem; +} + +.dashboard-compact .dashboard-project-content { + --dashboard-row-spacing: 0.5rem; + font-size: 0.875rem; +} + +.dashboard-project-card { + overflow-wrap: anywhere; +} diff --git a/src/app/dashboard/f-cross-dashboard.component.spec.ts b/src/app/dashboard/f-cross-dashboard.component.spec.ts new file mode 100644 index 0000000000..911b4019f3 --- /dev/null +++ b/src/app/dashboard/f-cross-dashboard.component.spec.ts @@ -0,0 +1,1678 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {TestbedHarnessEnvironment} from '@angular/cdk/testing/testbed'; +import {CommonModule} from '@angular/common'; +import {NO_ERRORS_SCHEMA} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {MatButtonModule} from '@angular/material/button'; +import {MatButtonHarness} from '@angular/material/button/testing'; +import {MatFormFieldModule} from '@angular/material/form-field'; +import {MatIconModule} from '@angular/material/icon'; +import {MatInputModule} from '@angular/material/input'; +import {MatMenuModule} from '@angular/material/menu'; +import {MatSelectModule} from '@angular/material/select'; +import {MatSelectHarness} from '@angular/material/select/testing'; +import {NoopAnimationsModule} from '@angular/platform-browser/animations'; +import {ActivatedRoute, Router, convertToParamMap} from '@angular/router'; +import {BehaviorSubject, Observable, ReplaySubject, Subject, of, throwError} from 'rxjs'; +import {Grade} from '../api/models/grade'; +import {Project} from '../api/models/project'; +import {Task} from '../api/models/task'; +import {TaskStatus, TaskStatusEnum} from '../api/models/task-status'; +import {ProjectService} from '../api/services/project.service'; +import { + TaskRecommendation, + TaskRecommendationService, +} from '../api/services/task-recommendation.service'; +import {TaskService} from '../api/services/task.service'; +import {GlobalStateService} from '../projects/states/index/global-state.service'; +import {CrossDashboardComponent} from './f-cross-dashboard.component'; + +describe('CrossDashboardComponent', () => { + let component: CrossDashboardComponent; + let fixture: ComponentFixture; + let projectsSubject: BehaviorSubject; + let projectServiceQuery: ReturnType; + let recommendationsSubject: ReplaySubject; + let recommendationServiceGetAll: ReturnType; + let taskStatusSubject: Subject; + let nextTaskDefinitionId: number; + + const syncView = async (): Promise => { + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + }; + + const makeDate = (day: number, hour = 12): Date => new Date(2026, 7, day, hour, 0, 0); + + const makeTask = ( + name: string, + abbreviation: string, + status: TaskStatusEnum, + dueDate: Date | null | undefined, + topWeight: number = 0, + targetGrade: number = 0, + targetGradeText: string = Grade.GRADES[targetGrade], + ): Task => + ({ + status, + topWeight, + numNewComments: 0, + localDueDate: vi.fn().mockReturnValue(dueDate), + inSubmittedState: vi.fn().mockReturnValue(TaskStatus.SUBMITTED_STATUSES.includes(status)), + definition: { + id: nextTaskDefinitionId++, + name, + abbreviation, + targetGrade, + targetGradeText, + description: `${name} description`, + targetDate: dueDate, + }, + }) as unknown as Task; + + const makeProject = ( + id: number, + code: string, + isActive: boolean, + tasks: Task[] = [], + name: string = `${code} Unit`, + ): Project => { + const project = { + id, + tasks, + unit: { + code, + name, + isActive, + taskDefinitions: [], + }, + calcTopTasks: vi.fn(), + activeTasks: vi.fn().mockReturnValue(tasks), + } as unknown as Project; + + tasks.forEach((task) => (task.project = project)); + return project; + }; + + beforeEach(async () => { + projectsSubject = new BehaviorSubject([]); + projectServiceQuery = vi.fn().mockReturnValue(of([])); + recommendationsSubject = new ReplaySubject(1); + recommendationsSubject.next([]); + recommendationServiceGetAll = vi.fn().mockReturnValue(recommendationsSubject.asObservable()); + taskStatusSubject = new Subject(); + nextTaskDefinitionId = 100; + + const globalStateServiceStub = { + projectLoadErrorSubject: new BehaviorSubject(false), + onLoad: (callback: () => void): void => callback(), + currentUserProjects: { + values: projectsSubject.asObservable(), + get currentValues(): Project[] { + return projectsSubject.value; + }, + }, + }; + + const projectServiceStub = { + query: vi.fn(() => { + throw new Error('Explicit refresh must bypass the query cache'); + }), + fetchAll: projectServiceQuery, + }; + + await TestBed.configureTestingModule({ + declarations: [CrossDashboardComponent], + imports: [ + CommonModule, + MatButtonModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatMenuModule, + MatSelectModule, + NoopAnimationsModule, + ], + providers: [ + {provide: ActivatedRoute, useValue: {queryParamMap: of(convertToParamMap({}))}}, + {provide: Router, useValue: {navigate: vi.fn().mockResolvedValue(true)}}, + { + provide: GlobalStateService, + useValue: globalStateServiceStub, + }, + { + provide: ProjectService, + useValue: projectServiceStub, + }, + { + provide: TaskRecommendationService, + useValue: {getAll: recommendationServiceGetAll}, + }, + { + provide: TaskService, + useValue: {taskStatusUpdated$: taskStatusSubject.asObservable()}, + }, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(CrossDashboardComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + projectsSubject.complete(); + recommendationsSubject.complete(); + taskStatusSubject.complete(); + }); + + const recommendationFor = (task: Task, projectId: number, score: number): TaskRecommendation => ({ + task_id: null, + task_definition_id: task.definition.id, + task_name: task.definition.name, + project_id: projectId, + unit_id: 20, + priority_score: score, + }); + + it('uses Active units as the default scope', () => { + expect(component.unitScope).toBe('active'); + expect(projectServiceQuery).not.toHaveBeenCalled(); + }); + + it('keeps only active projects in the active-unit collection', () => { + projectsSubject.next([makeProject(1, 'COS10001', true), makeProject(2, 'COS30046', false)]); + + expect(component.activeUnits.map((unit) => unit.code)).toEqual(['COS10001']); + expect(component.displayedUnits.map((unit) => unit.code)).toEqual(['COS10001']); + }); + + it('includes authorised tasks above the project target grade', () => { + const passTask = makeTask('Pass Task', 'P1', 'complete', makeDate(10), 0, 0); + const distinctionTask = makeTask('Distinction Task', 'D1', 'redo', makeDate(11), 0, 2); + const project = makeProject(1, 'COS20007', true, [passTask, distinctionTask]); + const targetGradeTasks = vi.fn().mockReturnValue([passTask]); + + project.targetGrade = 0; + project.activeTasks = targetGradeTasks; + projectsSubject.next([project]); + + expect(targetGradeTasks).not.toHaveBeenCalled(); + expect(component.displayedUnits[0].tasks.map((task) => task.abbreviation)).toEqual([ + 'D1', + 'P1', + ]); + }); + + it('maps each task using its effective due date and submitted state', () => { + const targetDate = makeDate(30); + const effectiveDate = makeDate(23); + const activeTask = makeTask('Active task', '1.1P', 'working_on_it', targetDate); + const submittedTask = makeTask('Submitted task', '1.2P', 'ready_for_feedback', targetDate); + + vi.mocked(activeTask.localDueDate).mockReturnValue(effectiveDate); + projectsSubject.next([makeProject(1, 'COS10001', true, [activeTask, submittedTask])]); + + expect(component.activeUnits[0].tasks[0]).toMatchObject({ + dueDate: effectiveDate, + showDueWarning: true, + }); + expect(component.activeUnits[0].tasks[1].showDueWarning).toBe(false); + }); + + it('maps feedback metadata and preserves missing metadata as unavailable', () => { + const noMetadataTask = makeTask('No feedback metadata', '1.1P', 'working_on_it', makeDate(10)); + const feedbackTask = makeTask('Task with feedback', '1.2P', 'fix_and_resubmit', makeDate(11)); + + feedbackTask.hasFeedback = true; + + projectsSubject.next([makeProject(1, 'COS10001', true, [noMetadataTask, feedbackTask])]); + + const mappedTasks = component.activeUnits[0].tasks; + + expect(mappedTasks.find((task) => task.abbreviation === '1.1P')?.hasFeedback).toBeNull(); + expect(mappedTasks.find((task) => task.abbreviation === '1.2P')?.hasFeedback).toBe(true); + }); + + it('loads and displays previous units in Previous units mode', () => { + projectServiceQuery.mockReturnValue( + of([makeProject(1, 'COS10001', true), makeProject(2, 'COS30046', false)]), + ); + + component.setUnitScope('previous'); + + expect(projectServiceQuery).toHaveBeenCalledTimes(1); + expect(component.previousUnitsLoaded).toBe(true); + expect(component.loadingPreviousUnits).toBe(false); + expect(component.previousUnits.map((unit) => unit.code)).toEqual(['COS30046']); + expect(component.displayedUnits.map((unit) => unit.code)).toEqual(['COS30046']); + expect(component.previousUnits[0].isPrevious).toBe(true); + }); + + it('shows active units first and previous units afterward in All units mode', () => { + projectsSubject.next([makeProject(1, 'COS10001', true), makeProject(2, 'COS20007', true)]); + + projectServiceQuery.mockReturnValue( + of([ + makeProject(1, 'COS10001', true), + makeProject(2, 'COS20007', true), + makeProject(3, 'COS30046', false), + ]), + ); + + component.setUnitScope('all'); + + expect(component.displayedUnits.map((unit) => unit.code)).toEqual([ + 'COS10001', + 'COS20007', + 'COS30046', + ]); + }); + + it('does not request previous units again after they have loaded', () => { + projectServiceQuery.mockReturnValue(of([makeProject(3, 'COS30046', false)])); + + component.setUnitScope('previous'); + component.setUnitScope('active'); + component.setUnitScope('all'); + + expect(projectServiceQuery).toHaveBeenCalledTimes(1); + }); + + it('renders the no-previous-units empty state', () => { + projectServiceQuery.mockReturnValue(of([])); + + component.setUnitScope('previous'); + fixture.detectChanges(); + + expect(component.previousUnitsLoaded).toBe(true); + expect(component.loadingPreviousUnits).toBe(false); + expect(component.previousUnits).toEqual([]); + expect(fixture.nativeElement.textContent).toContain('Previous units'); + expect(fixture.nativeElement.textContent).toContain('No previous units are available.'); + }); + + it('shows an error state when previous units cannot be loaded', () => { + projectServiceQuery.mockReturnValue( + throwError(() => new Error('Unable to load previous units')), + ); + + component.setUnitScope('previous'); + fixture.detectChanges(); + + expect(component.previousUnitsLoadError).toBe(true); + expect(component.loadingPreviousUnits).toBe(false); + expect(fixture.nativeElement.textContent).toContain('Previous units could not be loaded.'); + }); + + it('filters only the selected unit and ignores case and outer whitespace', async () => { + projectsSubject.next([ + makeProject(1, 'SIT764', true, [ + makeTask('Individual Retrospective', '5.1P', 'not_started', new Date(2026, 7, 12)), + makeTask('Leadership Report', '5.3D', 'not_started', new Date(2026, 7, 18)), + ]), + makeProject(2, 'SIT782', true, [ + makeTask('Security Report', '2.1P', 'not_started', new Date(2026, 7, 15)), + ]), + ]); + + await syncView(); + + expect(component.displayedUnits).toHaveLength(2); + + const searchInputs = fixture.nativeElement.querySelectorAll( + 'input[aria-label^="Search tasks in"]', + ) as NodeListOf; + + expect(searchInputs).toHaveLength(2); + + searchInputs[0].value = ' RETROSPECTIVE '; + searchInputs[0].dispatchEvent(new Event('input', {bubbles: true})); + + await syncView(); + + expect(component.displayedUnits[0].tasks.map((task) => task.abbreviation)).toEqual(['5.1P']); + + expect(component.displayedUnits[1].tasks.map((task) => task.abbreviation)).toEqual(['2.1P']); + }); + + it('matches abbreviation, status, unit code and displayed due date', () => { + projectsSubject.next([ + makeProject(1, 'SIT764', true, [ + makeTask('Individual Retrospective', '5.1P', 'ready_for_feedback', new Date(2026, 7, 12)), + ]), + ]); + + const matchingQueries = [ + '5.1p', + 'Awaiting Feedback', + 'sit764', + 'Wednesday 12 August', + '12/08/2026', + '2026-08-12', + ]; + + for (const query of matchingQueries) { + component.setSearch(1, query); + + expect(component.displayedUnits[0].tasks.map((task) => task.abbreviation)).toEqual(['5.1P']); + } + }); + + it('renders per-unit search with an explicit readable surface and placeholder', async () => { + projectsSubject.next([makeProject(1, 'SIT764', true)]); + + await syncView(); + + const searchInput = fixture.nativeElement.querySelector( + 'input[aria-label="Search tasks in SIT764"]', + ) as HTMLInputElement; + + expect(searchInput.classList.contains('bg-white')).toBe(true); + expect(searchInput.classList.contains('text-gray-900')).toBe(true); + expect(searchInput.classList.contains('placeholder:text-gray-600')).toBe(true); + expect(searchInput.classList.contains('placeholder:opacity-100')).toBe(true); + }); + + it('does not confuse Australian dates that contain the same numbers in another order', () => { + projectsSubject.next([ + makeProject(1, 'SIT764', true, [ + makeTask('December Task', 'DEC', 'not_started', new Date(2026, 11, 8)), + ]), + ]); + + component.setSearch(1, '12/08/2026'); + expect(component.displayedUnits[0].tasks).toEqual([]); + + component.setSearch(1, '08/12/2026'); + expect(component.displayedUnits[0].tasks.map((task) => task.abbreviation)).toEqual(['DEC']); + }); + + it('matches subtitle and description text shown on the task card', () => { + projectsSubject.next([ + makeProject(1, 'SIT764', true, [ + makeTask('Individual Retrospective', '5.1P', 'not_started', new Date(2026, 7, 12)), + ]), + ]); + + component.setSearch(1, 'Pass'); + expect(component.displayedUnits[0].tasks.map((task) => task.abbreviation)).toEqual(['5.1P']); + + component.setSearch(1, 'description'); + expect(component.displayedUnits[0].tasks.map((task) => task.abbreviation)).toEqual(['5.1P']); + + component.setSearch(1, 'not shown anywhere'); + expect(component.displayedUnits[0].tasks).toEqual([]); + }); + + it('keeps punctuation-only input visible without filtering tasks', async () => { + projectsSubject.next([ + makeProject(1, 'SIT764', true, [ + makeTask('Individual Retrospective', '5.1P', 'not_started', new Date(2026, 7, 12)), + makeTask('Leadership Report', '5.3D', 'not_started', new Date(2026, 7, 18)), + ]), + ]); + + await syncView(); + + const searchInput = fixture.nativeElement.querySelector( + 'input[aria-label^="Search tasks in"]', + ) as HTMLInputElement; + + searchInput.value = 'retro'; + searchInput.dispatchEvent(new Event('input', {bubbles: true})); + await syncView(); + + expect(component.displayedUnits[0].tasks.map((task) => task.abbreviation)).toEqual(['5.1P']); + + searchInput.value = '-'; + searchInput.dispatchEvent(new Event('input', {bubbles: true})); + await syncView(); + + expect(component.getSearchTerm(1)).toBe('-'); + expect(component.hasSearchTerm(1)).toBe(false); + expect(searchInput.value).toBe('-'); + expect(component.displayedUnits[0].tasks.map((task) => task.abbreviation)).toEqual([ + '5.1P', + '5.3D', + ]); + }); + + it('clears the search and restores the original task list', () => { + projectsSubject.next([ + makeProject(1, 'SIT764', true, [ + makeTask('Individual Retrospective', '5.1P', 'not_started', new Date(2026, 7, 12)), + makeTask('Leadership Report', '5.3D', 'not_started', new Date(2026, 7, 18)), + ]), + ]); + + component.setSearch(1, 'does not exist'); + + expect(component.displayedUnits[0].tasks).toEqual([]); + + component.setSearch(1, ' '); + + expect(component.displayedUnits[0].tasks.map((task) => task.abbreviation)).toEqual([ + '5.1P', + '5.3D', + ]); + }); + + it('combines search with the Hide Completed filter', () => { + projectsSubject.next([ + makeProject(1, 'SIT764', true, [ + makeTask('Completed Security Review', '1.1P', 'complete', new Date(2026, 7, 10)), + makeTask('Open Security Review', '1.2P', 'not_started', new Date(2026, 7, 12)), + makeTask('Open Calendar Review', '1.3P', 'not_started', new Date(2026, 7, 14)), + ]), + ]); + + component.setSearch(1, 'security'); + expect(component.displayedUnits[0].tasks.map((task) => task.abbreviation)).toEqual([ + '1.2P', + '1.1P', + ]); + + component.toggleFilter(1, component.filterOptions[0]); + expect(component.displayedUnits[0].tasks.map((task) => task.abbreviation)).toEqual(['1.2P']); + + component.setSearch(1, ''); + expect(component.displayedUnits[0].tasks.map((task) => task.abbreviation)).toEqual([ + '1.2P', + '1.3P', + ]); + }); + + it('preserves Due Date sorting after search is applied', () => { + projectsSubject.next([ + makeProject(1, 'SIT764', true, [ + makeTask('Later Security Review', 'LATE', 'not_started', new Date(2026, 7, 20)), + makeTask('Calendar Setup', 'OTHER', 'not_started', new Date(2026, 7, 5)), + makeTask('Earlier Security Review', 'EARLY', 'not_started', new Date(2026, 7, 10)), + ]), + ]); + + const dueDateSort = component.sortOptions.find((mode) => mode === 'Due Date'); + + if (!dueDateSort) { + throw new Error('Due Date sort option is missing'); + } + + component.setSort(1, dueDateSort); + component.setSearch(1, 'security'); + + expect(component.displayedUnits[0].tasks.map((task) => task.abbreviation)).toEqual([ + 'EARLY', + 'LATE', + ]); + }); + + it('sorts recommendations by project and task-definition id, including virtual tasks', () => { + const lowerPriority = makeTask('Lower priority', 'LOW', 'not_started', makeDate(10), 1); + const recommended = makeTask('Recommended next', 'NEXT', 'not_started', makeDate(20), 4); + const project = makeProject(7, 'DEMO20007', true, [lowerPriority, recommended]); + + projectsSubject.next([project]); + recommendationsSubject.next([recommendationFor(recommended, project.id, 95)]); + + expect(component.displayedUnits[0].tasks.map((task) => task.abbreviation)).toEqual([ + 'NEXT', + 'LOW', + ]); + }); + + it('refreshes recommendations after a status change in an active project', async () => { + const task = makeTask('Current task', 'CURRENT', 'working_on_it', makeDate(10)); + const project = makeProject(7, 'DEMO20007', true, [task]); + + projectsSubject.next([project]); + const callsBeforeStatusChange = recommendationServiceGetAll.mock.calls.length; + taskStatusSubject.next(task); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(recommendationServiceGetAll).toHaveBeenCalledTimes(callsBeforeStatusChange + 1); + }); + + it('refreshes recommendation scores whenever the active project cache changes', () => { + const callsBeforeProjectChanges = recommendationServiceGetAll.mock.calls.length; + + projectsSubject.next([makeProject(1, 'COS10001', true)]); + projectsSubject.next([makeProject(1, 'COS10001', true), makeProject(2, 'COS30046', true)]); + + expect(recommendationServiceGetAll).toHaveBeenCalledTimes(callsBeforeProjectChanges + 2); + }); + + it('does not refresh recommendations for task changes in an inactive project', async () => { + const task = makeTask('Previous task', 'PREVIOUS', 'working_on_it', makeDate(10)); + const project = makeProject(8, 'COS30046', false, [task]); + + projectsSubject.next([project]); + const callsBeforeStatusChange = recommendationServiceGetAll.mock.calls.length; + taskStatusSubject.next(task); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(recommendationServiceGetAll).toHaveBeenCalledTimes(callsBeforeStatusChange); + }); + + it('cancels a recommendation request superseded by a newer project snapshot', () => { + const cancelled = vi.fn(); + const pendingRequest: Observable = new Observable(() => cancelled); + + recommendationServiceGetAll.mockImplementationOnce(() => pendingRequest); + recommendationServiceGetAll.mockImplementation(() => of([])); + + projectsSubject.next([makeProject(1, 'COS10001', true)]); + expect(cancelled).not.toHaveBeenCalled(); + + projectsSubject.next([makeProject(1, 'COS10001', true), makeProject(2, 'COS20007', true)]); + + expect(cancelled).toHaveBeenCalledOnce(); + }); + + it('retries after a recommendation error on the next active task status change', async () => { + const lowerPriority = makeTask('Lower priority', 'LOW', 'working_on_it', makeDate(10), 0); + const higherPriority = makeTask('Higher priority', 'HIGH', 'working_on_it', makeDate(20), 5); + const project = makeProject(1, 'COS10001', true, [lowerPriority, higherPriority]); + + recommendationServiceGetAll.mockImplementationOnce(() => + throwError(() => new Error('Recommendations unavailable')), + ); + recommendationServiceGetAll.mockImplementation(() => + of([ + recommendationFor(lowerPriority, project.id, 25), + recommendationFor(higherPriority, project.id, 90), + ]), + ); + + projectsSubject.next([project]); + expect(component.displayedUnits[0].tasks.map((task) => task.abbreviation)).toEqual([ + 'LOW', + 'HIGH', + ]); + + taskStatusSubject.next(lowerPriority); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(component.displayedUnits[0].tasks.map((task) => task.abbreviation)).toEqual([ + 'HIGH', + 'LOW', + ]); + }); + + it('stops project and task-status refreshes after destruction', async () => { + const task = makeTask('Current task', 'CURRENT', 'working_on_it', makeDate(10)); + const project = makeProject(1, 'COS10001', true, [task]); + + projectsSubject.next([project]); + const callsBeforeDestruction = recommendationServiceGetAll.mock.calls.length; + fixture.destroy(); + + projectsSubject.next([project, makeProject(2, 'COS20007', true)]); + taskStatusSubject.next(task); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(recommendationServiceGetAll).toHaveBeenCalledTimes(callsBeforeDestruction); + }); + + it('keeps final-state tasks below open tasks in every sort mode', () => { + projectsSubject.next([ + makeProject(1, 'SIT764', true, [ + makeTask('Completed First', 'DONE-FIRST', 'complete', makeDate(3), 0), + makeTask('Open Later', 'OPEN-LATE', 'not_started', makeDate(20), 4), + makeTask('Final Later', 'DONE-LATE', 'fail', makeDate(25), 5), + makeTask('Open Earlier', 'OPEN-EARLY', 'working_on_it', makeDate(10), 1), + ]), + ]); + + const expectedByMode: Record = { + Recommended: ['OPEN-EARLY', 'OPEN-LATE', 'DONE-FIRST', 'DONE-LATE'], + 'Due Date': ['OPEN-EARLY', 'OPEN-LATE', 'DONE-FIRST', 'DONE-LATE'], + Default: ['OPEN-EARLY', 'OPEN-LATE', 'DONE-FIRST', 'DONE-LATE'], + }; + + component.sortOptions.forEach((mode) => { + component.setSort(1, mode); + + expect(component.displayedUnits[0].tasks.map((task) => task.abbreviation)).toEqual( + expectedByMode[mode], + ); + }); + }); + + it('applies separate search state in All and Previous unit scopes', () => { + projectsSubject.next([ + makeProject(1, 'SIT764', true, [ + makeTask('Current Task', 'CUR', 'not_started', new Date(2026, 7, 12)), + ]), + ]); + + projectServiceQuery.mockReturnValue( + of([ + makeProject(2, 'SIT704', false, [ + makeTask('Archived Security Task', 'OLD1', 'complete', new Date(2025, 7, 12)), + makeTask('Archived Project Task', 'OLD2', 'complete', new Date(2025, 7, 15)), + ]), + ]), + ); + + component.setUnitScope('all'); + component.setSearch(2, 'security'); + + expect(component.displayedUnits[0].tasks.map((task) => task.abbreviation)).toEqual(['CUR']); + + expect(component.displayedUnits[1].tasks.map((task) => task.abbreviation)).toEqual(['OLD1']); + + component.setUnitScope('previous'); + + expect(component.displayedUnits.map((unit) => unit.code)).toEqual(['SIT704']); + expect(component.displayedUnits[0].tasks.map((task) => task.abbreviation)).toEqual(['OLD1']); + }); + + it('shows a no-results message without hiding the unit', async () => { + projectsSubject.next([ + makeProject(1, 'SIT764', true, [ + makeTask('Individual Retrospective', '5.1P', 'not_started', new Date(2026, 7, 12)), + ]), + ]); + + await syncView(); + + expect(fixture.nativeElement.textContent).toContain('SIT764'); + expect(fixture.nativeElement.querySelectorAll('f-dashboard-list-item')).toHaveLength(1); + + component.setSearch(1, 'not a matching task'); + + await syncView(); + + expect(component.displayedUnits[0].tasks).toEqual([]); + expect(fixture.nativeElement.textContent).toContain('SIT764'); + expect(fixture.nativeElement.textContent).toContain( + 'No tasks match the current search and filters.', + ); + expect(fixture.nativeElement.querySelectorAll('f-dashboard-list-item')).toHaveLength(0); + + component.setSearch(1, ''); + + await syncView(); + + expect(component.displayedUnits[0].tasks).toHaveLength(1); + expect(component.displayedUnits[0].tasks[0].title).toBe('Individual Retrospective'); + expect(fixture.nativeElement.querySelectorAll('f-dashboard-list-item')).toHaveLength(1); + expect(fixture.nativeElement.textContent).not.toContain( + 'No tasks match the current search and filters.', + ); + }); + + it('includes tasks exactly on the start and end boundaries', () => { + projectsSubject.next([ + makeProject(1, 'SIT764', true, [ + makeTask('Before Task', 'BEFORE', 'not_started', makeDate(9)), + makeTask('Start Task', 'START', 'not_started', makeDate(10, 23)), + makeTask('End Task', 'END', 'not_started', makeDate(20, 1)), + makeTask('After Task', 'AFTER', 'not_started', makeDate(21)), + ]), + ]); + + component.setStartDate('2026-08-10'); + component.setEndDate('2026-08-20'); + + expect(component.displayedUnits[0].tasks.map((task) => task.abbreviation)).toEqual([ + 'START', + 'END', + ]); + }); + + it('supports start-date-only filtering', () => { + projectsSubject.next([ + makeProject(1, 'SIT764', true, [ + makeTask('Before Task', 'BEFORE', 'not_started', makeDate(9)), + makeTask('Boundary Task', 'BOUNDARY', 'not_started', makeDate(10)), + makeTask('After Task', 'AFTER', 'not_started', makeDate(11)), + ]), + ]); + + component.setStartDate('2026-08-10'); + + expect(component.displayedUnits[0].tasks.map((task) => task.abbreviation)).toEqual([ + 'BOUNDARY', + 'AFTER', + ]); + }); + + it('supports end-date-only filtering', () => { + projectsSubject.next([ + makeProject(1, 'SIT764', true, [ + makeTask('Before Task', 'BEFORE', 'not_started', makeDate(9)), + makeTask('Boundary Task', 'BOUNDARY', 'not_started', makeDate(10)), + makeTask('After Task', 'AFTER', 'not_started', makeDate(11)), + ]), + ]); + + component.setEndDate('2026-08-10'); + + expect(component.displayedUnits[0].tasks.map((task) => task.abbreviation)).toEqual([ + 'BEFORE', + 'BOUNDARY', + ]); + }); + + it('detects a reversed date range and does not apply it', () => { + projectsSubject.next([ + makeProject(1, 'SIT764', true, [ + makeTask('First Task', 'TASK1', 'not_started', makeDate(10)), + makeTask('Second Task', 'TASK2', 'not_started', makeDate(20)), + ]), + ]); + + component.setStartDate('2026-08-20'); + component.setEndDate('2026-08-10'); + + expect(component.startDate).toBe('2026-08-20'); + expect(component.endDate).toBe('2026-08-10'); + expect(component.isDateRangeInvalid).toBe(true); + expect(component.isDateFilterActive).toBe(false); + expect(component.displayedUnits[0].tasks.map((task) => task.abbreviation)).toEqual([ + 'TASK1', + 'TASK2', + ]); + }); + + it('keeps undated tasks without a filter and excludes them while filtering', () => { + projectsSubject.next([ + makeProject(1, 'SIT764', true, [ + makeTask('Valid Task', 'VALID', 'not_started', makeDate(15)), + makeTask('Missing Date Task', 'MISSING', 'not_started', undefined), + makeTask('Invalid Date Task', 'INVALID', 'not_started', new Date('invalid')), + ]), + ]); + + expect(component.displayedUnits[0].tasks.map((task) => task.abbreviation)).toEqual([ + 'VALID', + 'MISSING', + 'INVALID', + ]); + + component.setStartDate('2026-08-10'); + + expect(component.displayedUnits[0].tasks.map((task) => task.abbreviation)).toEqual(['VALID']); + }); + + it('clears both dates and restores the unfiltered task list', () => { + projectsSubject.next([ + makeProject(1, 'SIT764', true, [ + makeTask('Early Task', 'EARLY', 'not_started', makeDate(5)), + makeTask('Late Task', 'LATE', 'not_started', makeDate(20)), + ]), + ]); + + component.setStartDate('2026-08-10'); + expect(component.displayedUnits[0].tasks.map((task) => task.abbreviation)).toEqual(['LATE']); + + component.clearDateRange(); + + expect(component.startDate).toBe(''); + expect(component.endDate).toBe(''); + expect(component.isDateFilterActive).toBe(false); + expect(component.displayedUnits[0].tasks.map((task) => task.abbreviation)).toEqual([ + 'EARLY', + 'LATE', + ]); + }); + + it('keeps a unit visible when no tasks match the selected date range', () => { + projectsSubject.next([ + makeProject(1, 'SIT764', true, [makeTask('Early Task', 'TASK1', 'not_started', makeDate(5))]), + ]); + + component.setStartDate('2026-08-20'); + + expect(component.isDateFilterActive).toBe(true); + expect(component.displayedUnits).toHaveLength(1); + expect(component.displayedUnits[0].code).toBe('SIT764'); + expect(component.displayedUnits[0].tasks).toHaveLength(0); + }); + + it('combines date filtering with Hide Completed and Due Date sorting', () => { + projectsSubject.next([ + makeProject(1, 'SIT764', true, [ + makeTask('Later Review', 'LATER', 'not_started', makeDate(18), 3), + makeTask('Completed Review', 'COMPLETE', 'complete', makeDate(12), 1), + makeTask('Earlier Review', 'EARLIER', 'not_started', makeDate(15), 2), + makeTask('Outside Review', 'OUTSIDE', 'not_started', makeDate(25), 4), + ]), + ]); + + component.setStartDate('2026-08-10'); + component.setEndDate('2026-08-20'); + + const hideCompleted = component.filterOptions.find((mode) => mode === 'Hide Completed'); + if (!hideCompleted) { + throw new Error('Hide Completed filter option is missing'); + } + component.toggleFilter(1, hideCompleted); + + const dueDateSort = component.sortOptions.find((mode) => mode === 'Due Date'); + if (!dueDateSort) { + throw new Error('Due Date sort option is missing'); + } + component.setSort(1, dueDateSort); + + expect(component.displayedUnits[0].tasks.map((task) => task.abbreviation)).toEqual([ + 'EARLIER', + 'LATER', + ]); + }); + + it('applies the same date range in Active, Previous and All unit views', () => { + const activeProject = makeProject(1, 'SIT764', true, [ + makeTask('Active Early Task', 'ACTIVE-EARLY', 'not_started', makeDate(10)), + ]); + const previousProject = makeProject(2, 'SIT704', false, [ + makeTask('Previous Late Task', 'PREVIOUS-LATE', 'not_started', makeDate(20)), + ]); + + projectsSubject.next([activeProject]); + projectServiceQuery.mockReturnValue(of([activeProject, previousProject])); + + component.setStartDate('2026-08-15'); + expect(component.displayedUnits.map((unit) => unit.code)).toEqual(['SIT764']); + expect(component.displayedUnits[0].tasks).toHaveLength(0); + + component.setUnitScope('previous'); + expect(component.displayedUnits.map((unit) => unit.code)).toEqual(['SIT704']); + expect(component.displayedUnits[0].tasks.map((task) => task.abbreviation)).toEqual([ + 'PREVIOUS-LATE', + ]); + + component.setUnitScope('all'); + expect(component.displayedUnits.map((unit) => unit.code)).toEqual(['SIT764', 'SIT704']); + expect(component.displayedUnits[0].tasks).toHaveLength(0); + expect(component.displayedUnits[1].tasks.map((task) => task.abbreviation)).toEqual([ + 'PREVIOUS-LATE', + ]); + }); + + it('combines per-unit search with dashboard date filtering', () => { + projectsSubject.next([ + makeProject(1, 'SIT764', true, [ + makeTask('Early Security Review', 'EARLY', 'not_started', makeDate(8)), + makeTask('In-range Security Review', 'MATCH', 'not_started', makeDate(15)), + makeTask('In-range Calendar Setup', 'OTHER', 'not_started', makeDate(16)), + ]), + ]); + + component.setSearch(1, 'security'); + component.setStartDate('2026-08-10'); + component.setEndDate('2026-08-20'); + + expect(component.displayedUnits[0].tasks.map((task) => task.abbreviation)).toEqual(['MATCH']); + }); + + it('renders one labelled global toolbar with canonical status and grade choices', async () => { + projectsSubject.next([ + makeProject(1, 'SIT764', true, [ + makeTask('Individual Retrospective', '5.1P', 'not_started', makeDate(12)), + ]), + ]); + + await syncView(); + + expect( + fixture.nativeElement.querySelectorAll('[aria-label="Global dashboard filters"]'), + ).toHaveLength(1); + expect( + fixture.nativeElement.querySelectorAll('input[aria-label="Global search"]'), + ).toHaveLength(1); + expect( + fixture.nativeElement.querySelectorAll('input[aria-label^="Search tasks in"]'), + ).toHaveLength(1); + + expect(component.statusOptions).toEqual( + TaskStatus.STATUS_KEYS.map((value) => ({ + value, + label: TaskStatus.STATUS_LABELS.get(value), + })), + ); + expect(component.statusOptions).toEqual( + expect.arrayContaining([ + {value: 'complete', label: 'Complete'}, + {value: 'not_started', label: 'Not Started'}, + {value: 'redo', label: 'Redo'}, + {value: 'fix_and_resubmit', label: 'Resubmit'}, + ]), + ); + expect(component.gradeOptions).toEqual([ + {value: 0, label: 'Pass'}, + {value: 1, label: 'Credit'}, + {value: 2, label: 'Distinction'}, + {value: 3, label: 'High Distinction'}, + ]); + }); + + it('binds both multiple-select controls and Clear all through the rendered toolbar', async () => { + projectsSubject.next([ + makeProject(1, 'SIT764', true, [ + makeTask('Security Pass Task', 'P1', 'complete', makeDate(10), 0, 0), + makeTask('Security Distinction Task', 'D1', 'redo', makeDate(11), 0, 2), + ]), + ]); + await syncView(); + + const mobileFilterToggle = fixture.nativeElement.querySelector( + 'button[data-mobile-global-filter-toggle]', + ) as HTMLButtonElement; + mobileFilterToggle.click(); + await syncView(); + + const loader = TestbedHarnessEnvironment.loader(fixture); + const statusSelect = await loader.getHarness( + MatSelectHarness.with({selector: '[aria-label="Task statuses"]'}), + ); + const gradeSelect = await loader.getHarness( + MatSelectHarness.with({selector: '[aria-label="Target grades"]'}), + ); + + expect(await statusSelect.isMultiple()).toBe(true); + expect(await gradeSelect.isMultiple()).toBe(true); + + await statusSelect.open(); + await statusSelect.clickOptions({text: /^(Redo|Complete)$/}); + await statusSelect.close(); + + await gradeSelect.open(); + await gradeSelect.clickOptions({text: /^(Pass|Distinction)$/}); + await gradeSelect.close(); + + const globalSearch = fixture.nativeElement.querySelector( + 'input[aria-label="Global search"]', + ) as HTMLInputElement; + globalSearch.value = 'security'; + globalSearch.dispatchEvent(new Event('input', {bubbles: true})); + component.setStartDate('2026-08-01'); + await syncView(); + + expect(component.selectedStatuses.slice().sort()).toEqual(['complete', 'redo']); + expect(component.selectedGrades).toEqual([0, 2]); + expect(component.mobileSecondaryFilterCount).toBe(5); + expect(await statusSelect.getValueText()).toContain('Redo'); + expect(await statusSelect.getValueText()).toContain('Complete'); + expect(await gradeSelect.getValueText()).toContain('Pass'); + expect(await gradeSelect.getValueText()).toContain('Distinction'); + + const clearAll = await loader.getHarness(MatButtonHarness.with({text: 'Clear all'})); + expect(await clearAll.isDisabled()).toBe(false); + await clearAll.click(); + await syncView(); + + expect(component.globalSearchTerm).toBe(''); + expect(component.selectedStatuses).toEqual([]); + expect(component.selectedGrades).toEqual([]); + expect(component.startDate).toBe(''); + expect(component.mobileSecondaryFilterCount).toBe(0); + expect(component.mobileGlobalFiltersExpanded).toBe(true); + expect(globalSearch.value).toBe(''); + expect(await statusSelect.getValueText()).toBe(''); + expect(await gradeSelect.getValueText()).toBe(''); + expect(await clearAll.isDisabled()).toBe(true); + }); + + it('keeps secondary filters in a labelled mobile disclosure with an active count', async () => { + projectsSubject.next([makeProject(1, 'SIT764', true)]); + await syncView(); + + const toggle = fixture.nativeElement.querySelector( + 'button[data-mobile-global-filter-toggle]', + ) as HTMLButtonElement; + const filters = fixture.nativeElement.querySelector( + '#dashboard-secondary-filters', + ) as HTMLElement; + + expect(toggle.classList).toContain('min-h-12'); + expect(toggle.getAttribute('aria-controls')).toBe('dashboard-secondary-filters'); + expect(toggle.getAttribute('aria-expanded')).toBe('false'); + expect(filters.classList).not.toContain('is-mobile-open'); + expect(toggle.textContent).not.toContain('active'); + + component.setStatuses(['complete', 'redo']); + component.setGrades([1]); + component.setStartDate('2026-08-01'); + await syncView(); + + expect(component.mobileSecondaryFilterCount).toBe(4); + expect(toggle.textContent).toContain('4 active'); + + toggle.click(); + await syncView(); + + expect(toggle.getAttribute('aria-expanded')).toBe('true'); + expect(filters.classList).toContain('is-mobile-open'); + }); + + it('globally searches every required unit and task field case-insensitively', () => { + projectsSubject.next([ + makeProject( + 1, + 'SIT764', + true, + [ + makeTask( + 'Individual Retrospective', + '5.1D', + 'ready_for_feedback', + new Date(2026, 7, 12), + 0, + 2, + ), + ], + 'Human Centred Design', + ), + makeProject( + 2, + 'SIT782', + true, + [makeTask('Network Plan', '2.1P', 'not_started', new Date(2026, 7, 18))], + 'Secure Networks', + ), + ]); + + const matchingQueries = [ + ' sit764 ', + 'HUMAN CENTRED', + 'individual retrospective', + '5.1d', + 'Awaiting Feedback', + 'Distinction', + 'Wednesday 12 August', + '12/08/2026', + '2026-08-12', + ]; + + for (const query of matchingQueries) { + component.setGlobalSearch(query); + + expect(component.displayedUnits.map((unit) => unit.code)).toEqual(['SIT764']); + expect(component.displayedUnits[0].tasks.map((task) => task.abbreviation)).toEqual(['5.1D']); + } + + component.setGlobalSearch(' - '); + expect(component.globalSearchTerm).toBe(' - '); + expect(component.hasGlobalTaskCriteria).toBe(false); + expect(component.hasGlobalToolbarChanges).toBe(true); + expect(component.displayedUnits.map((unit) => unit.code)).toEqual(['SIT764', 'SIT782']); + + component.clearGlobalFilters(); + expect(component.globalSearchTerm).toBe(''); + expect(component.hasGlobalToolbarChanges).toBe(false); + }); + + it('filters by every trusted target-grade value and supports multiple grades', () => { + projectsSubject.next([ + makeProject(1, 'SIT764', true, [ + makeTask('Pass Task', 'LOOKS-HD', 'not_started', makeDate(10), 0, 0), + makeTask('Credit Task', 'CREDIT', 'not_started', makeDate(11), 0, 1), + makeTask('Distinction Task', 'DISTINCTION', 'not_started', makeDate(12), 0, 2), + makeTask('High Distinction Task', 'LOOKS-P', 'not_started', makeDate(13), 0, 3), + ]), + ]); + + for (const option of component.gradeOptions) { + component.setGrades([option.value]); + + expect(component.displayedUnits[0].tasks).toHaveLength(1); + expect(component.displayedUnits[0].tasks[0].targetGrade).toBe(option.value); + expect(component.displayedUnits[0].tasks[0].targetGradeLabel).toBe(option.label); + } + + component.setGrades([0, 2]); + + expect(component.displayedUnits[0].tasks.map((task) => task.targetGrade)).toEqual([0, 2]); + expect(component.displayedUnits[0].tasks.map((task) => task.abbreviation)).toEqual([ + 'LOOKS-HD', + 'DISTINCTION', + ]); + }); + + it('filters by every existing task status and supports multiple statuses', () => { + const tasks = TaskStatus.STATUS_KEYS.map((status, index) => + makeTask( + `${TaskStatus.STATUS_LABELS.get(status)} Task`, + `TASK-${index}`, + status, + makeDate(10), + ), + ); + projectsSubject.next([makeProject(1, 'SIT764', true, tasks)]); + + for (const status of TaskStatus.STATUS_KEYS) { + component.setStatuses([status]); + + expect(component.displayedUnits[0].tasks).toHaveLength(1); + expect(component.displayedUnits[0].tasks[0].status).toBe(status); + } + + component.setStatuses(['complete', 'redo', 'fix_and_resubmit']); + + expect(component.displayedUnits[0].tasks.map((task) => task.status).sort()).toEqual([ + 'complete', + 'fix_and_resubmit', + 'redo', + ]); + }); + + it('uses OR within filter groups and AND across search, status and grade groups', () => { + projectsSubject.next([ + makeProject(1, 'SIT764', true, [ + makeTask('Security Pass', 'MATCH-P', 'complete', makeDate(10), 0, 0), + makeTask('Security Credit', 'MATCH-C', 'fix_and_resubmit', makeDate(11), 0, 1), + makeTask('Security Distinction', 'GRADE-OUT', 'complete', makeDate(12), 0, 2), + makeTask('Security Redo', 'STATUS-OUT', 'redo', makeDate(13), 0, 0), + makeTask('Calendar Credit', 'SEARCH-OUT', 'complete', makeDate(14), 0, 1), + ]), + ]); + + component.setGlobalSearch('security'); + component.setStatuses(['complete', 'fix_and_resubmit']); + component.setGrades([0, 1]); + + expect(component.displayedUnits[0].tasks.map((task) => task.abbreviation)).toEqual([ + 'MATCH-C', + 'MATCH-P', + ]); + }); + + it('applies global criteria in Previous units scope after authorised units load', () => { + projectServiceQuery.mockReturnValue( + of([ + makeProject(2, 'SIT704', false, [ + makeTask('Archived Pass Task', 'OLD-P', 'complete', makeDate(10), 0, 0), + makeTask('Archived Distinction Task', 'OLD-D', 'complete', makeDate(11), 0, 2), + makeTask('Archived Distinction Redo', 'OLD-REDO', 'redo', makeDate(12), 0, 2), + ]), + ]), + ); + + component.setUnitScope('previous'); + component.setGlobalSearch('archived'); + component.setStatuses(['complete']); + component.setGrades([2]); + + expect(component.unitScope).toBe('previous'); + expect(component.displayedUnits.map((unit) => unit.code)).toEqual(['SIT704']); + expect(component.displayedUnits[0].tasks.map((task) => task.abbreviation)).toEqual(['OLD-D']); + }); + + it('applies global search before an independent per-unit search', () => { + projectsSubject.next([ + makeProject(1, 'SIT764', true, [ + makeTask('Security Report', 'REPORT', 'not_started', makeDate(10)), + makeTask('Security Plan', 'PLAN', 'not_started', makeDate(11)), + ]), + makeProject(2, 'SIT782', true, [ + makeTask('Security Calendar', 'CALENDAR', 'not_started', makeDate(12)), + ]), + ]); + + component.setGlobalSearch('security'); + + expect(component.displayedUnits.map((unit) => unit.code)).toEqual(['SIT764', 'SIT782']); + expect(component.displayedUnits[0].tasks.map((task) => task.abbreviation)).toEqual([ + 'REPORT', + 'PLAN', + ]); + expect(component.displayedUnits[1].tasks.map((task) => task.abbreviation)).toEqual([ + 'CALENDAR', + ]); + + component.setSearch(1, 'report'); + + expect(component.displayedUnits.map((unit) => unit.code)).toEqual(['SIT764', 'SIT782']); + expect(component.displayedUnits[0].tasks.map((task) => task.abbreviation)).toEqual(['REPORT']); + expect(component.displayedUnits[1].tasks.map((task) => task.abbreviation)).toEqual([ + 'CALENDAR', + ]); + + component.setSearch(1, ''); + + expect(component.displayedUnits[0].tasks.map((task) => task.abbreviation)).toEqual([ + 'REPORT', + 'PLAN', + ]); + }); + + it('clears global criteria, restores Active units and leaves canonical results', () => { + const activeProject = makeProject(1, 'SIT764', true, [ + makeTask('Active Pass Task', 'ACTIVE-P', 'complete', makeDate(10), 0, 0), + makeTask('Active Credit Task', 'ACTIVE-C', 'not_started', makeDate(11), 0, 1), + ]); + const previousProject = makeProject(2, 'SIT704', false, [ + makeTask('Previous Pass Task', 'PREVIOUS-P', 'complete', makeDate(12), 0, 0), + ]); + + projectsSubject.next([activeProject]); + projectServiceQuery.mockReturnValue(of([activeProject, previousProject])); + + component.setUnitScope('all'); + component.setGlobalSearch('pass'); + component.setStatuses(['complete']); + component.setGrades([0]); + component.setStartDate('2026-08-01'); + component.setEndDate('2026-08-31'); + + expect(component.hasGlobalToolbarChanges).toBe(true); + expect(component.displayedUnits.map((unit) => unit.code)).toEqual(['SIT764', 'SIT704']); + + component.clearGlobalFilters(); + + expect(component.unitScope).toBe('active'); + expect(component.globalSearchTerm).toBe(''); + expect(component.selectedStatuses).toEqual([]); + expect(component.selectedGrades).toEqual([]); + expect(component.startDate).toBe(''); + expect(component.endDate).toBe(''); + expect(component.hasGlobalToolbarChanges).toBe(false); + expect(component.displayedUnits.map((unit) => unit.code)).toEqual(['SIT764']); + expect(component.displayedUnits[0].tasks.map((task) => task.abbreviation)).toEqual([ + 'ACTIVE-C', + 'ACTIVE-P', + ]); + expect(component.displayedUnits[0].gradeSummaries).toEqual([]); + }); + + it('removes globally empty units and renders the global no-results state', async () => { + projectsSubject.next([ + makeProject(1, 'SIT764', true, [ + makeTask('Individual Retrospective', '5.1P', 'not_started', makeDate(12)), + ]), + ]); + + component.setGlobalSearch('not a matching task'); + await syncView(); + + expect(component.displayedUnits).toEqual([]); + expect(fixture.nativeElement.textContent).not.toContain('SIT764'); + expect(fixture.nativeElement.textContent).toContain( + 'No tasks match the current global search and filters.', + ); + expect(fixture.nativeElement.textContent).not.toContain( + 'No tasks match the current search and filters.', + ); + }); + + it('renders the global no-results state when the selected scope contains no units', async () => { + projectsSubject.next([]); + + component.setGlobalSearch('security'); + await syncView(); + + expect(component.displayedUnits).toEqual([]); + expect(fixture.nativeElement.textContent).toContain( + 'No tasks match the current global search and filters.', + ); + }); + + it('keeps grade summaries independent of global and per-unit search and status filters', async () => { + projectsSubject.next([ + makeProject(1, 'SIT764', true, [ + makeTask('Completed Pass One', 'P1', 'complete', makeDate(10), 0, 0), + makeTask('Completed Pass Two', 'P2', 'complete', makeDate(11), 0, 0), + makeTask('Open Pass One', 'P3', 'not_started', makeDate(12), 0, 0), + makeTask('Portfolio Pass', 'P4', 'assess_in_portfolio', makeDate(13), 0, 0), + ]), + ]); + + component.setGrades([0]); + component.setStatuses(['not_started']); + component.setGlobalSearch('open'); + component.setSearch(1, 'one'); + await syncView(); + + expect(component.displayedUnits[0].tasks.map((task) => task.abbreviation)).toEqual(['P3']); + expect(component.displayedUnits[0].gradeSummaries).toEqual([ + { + targetGrade: 0, + label: 'Pass', + completed: 2, + total: 4, + percentage: 50, + }, + ]); + expect(fixture.nativeElement.textContent).toContain('Pass: 2 of 4 complete (50%)'); + }); + + it('shows a zero-task summary for a selected grade on an otherwise visible unit', async () => { + projectsSubject.next([ + makeProject(1, 'SIT764', true, [ + makeTask('Credit Task', 'C1', 'not_started', makeDate(10), 0, 1), + ]), + ]); + + component.setGrades([0, 1]); + await syncView(); + + expect(component.displayedUnits).toHaveLength(1); + expect(component.displayedUnits[0].gradeSummaries).toEqual([ + { + targetGrade: 0, + label: 'Pass', + completed: 0, + total: 0, + percentage: null, + }, + { + targetGrade: 1, + label: 'Credit', + completed: 0, + total: 1, + percentage: 0, + }, + ]); + expect(fixture.nativeElement.textContent).toContain('Pass: 0 tasks'); + expect(fixture.nativeElement.textContent).toContain('Credit: 0 of 1 complete (0%)'); + expect(fixture.nativeElement.textContent).not.toContain('Pass: 0 of 0 complete (0%)'); + }); + + it('calculates summaries independently per unit using trusted task-definition labels', () => { + projectsSubject.next([ + makeProject(1, 'SIT764', true, [ + makeTask('Foundation Complete', 'F1', 'complete', makeDate(10), 0, 0, 'Foundation'), + makeTask('Foundation Open', 'F2', 'not_started', makeDate(11), 0, 0, 'Foundation'), + ]), + makeProject(2, 'SIT782', true, [ + makeTask('Entry Complete One', 'E1', 'complete', makeDate(12), 0, 0, 'Entry'), + makeTask('Entry Complete Two', 'E2', 'complete', makeDate(13), 0, 0, 'Entry'), + makeTask('Entry Open', 'E3', 'redo', makeDate(14), 0, 0, 'Entry'), + ]), + ]); + + component.setGrades([0]); + + expect(component.displayedUnits[0].gradeSummaries).toEqual([ + { + targetGrade: 0, + label: 'Foundation', + completed: 1, + total: 2, + percentage: 50, + }, + ]); + expect(component.displayedUnits[1].gradeSummaries).toEqual([ + { + targetGrade: 0, + label: 'Entry', + completed: 2, + total: 3, + percentage: 67, + }, + ]); + }); + + it('hides all grade summaries until a grade filter is selected', async () => { + projectsSubject.next([ + makeProject(1, 'SIT764', true, [makeTask('Pass Task', 'P1', 'complete', makeDate(10), 0, 0)]), + ]); + + await syncView(); + + expect(component.displayedUnits[0].gradeSummaries).toEqual([]); + expect(fixture.nativeElement.textContent).not.toContain('Grade task completion'); + }); + + it('renders one accessible mobile accordion control and summary per unit', async () => { + const overdueDate = new Date(Date.now() - 24 * 60 * 60 * 1000); + const laterDate = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000); + + projectsSubject.next([ + makeProject( + 1, + 'SIT764', + true, + [ + makeTask('Overdue review', 'OVERDUE', 'not_started', overdueDate), + makeTask('Later review', 'LATER', 'not_started', laterDate), + ], + 'Human Centred Design', + ), + makeProject(2, 'SIT782', true, [makeTask('Submitted plan', 'DONE', 'complete', laterDate)]), + ]); + + await syncView(); + + const toggles = fixture.nativeElement.querySelectorAll( + 'button[data-mobile-unit-toggle]', + ) as NodeListOf; + + expect(toggles).toHaveLength(2); + expect(toggles[0].parentElement?.tagName).toBe('H2'); + expect(toggles[0].classList).toContain('min-h-12'); + expect(toggles[0].getAttribute('aria-expanded')).toBe('false'); + expect(toggles[0].getAttribute('aria-controls')).toBe( + 'dashboard-unit-controls-1 dashboard-unit-content-1', + ); + expect(toggles[0].textContent).toContain('SIT764'); + expect(toggles[0].textContent).toContain('Human Centred Design ยท Active'); + expect(toggles[0].textContent).toContain('2 tasks'); + expect(toggles[0].textContent).toContain('Overdue: OVERDUE'); + expect(toggles[1].textContent).toContain('1 task'); + expect(toggles[1].textContent).toContain('No upcoming deadlines'); + expect(toggles[0].querySelector('a, input, button')).toBeNull(); + expect(fixture.nativeElement.querySelector('#dashboard-unit-controls-1')).not.toBeNull(); + expect(fixture.nativeElement.querySelector('#dashboard-unit-content-1')).not.toBeNull(); + + // The responsive view reuses each task row instead of duplicating separate mobile markup. + expect(fixture.nativeElement.querySelectorAll('f-dashboard-list-item')).toHaveLength(3); + }); + + it('opens only one mobile unit at a time and resets expansion when scope changes', async () => { + projectsSubject.next([makeProject(1, 'SIT764', true), makeProject(2, 'SIT782', true)]); + await syncView(); + + const toggles = fixture.nativeElement.querySelectorAll( + 'button[data-mobile-unit-toggle]', + ) as NodeListOf; + const firstControls = fixture.nativeElement.querySelector( + '#dashboard-unit-controls-1', + ) as HTMLElement; + const firstContent = fixture.nativeElement.querySelector( + '#dashboard-unit-content-1', + ) as HTMLElement; + const secondContent = fixture.nativeElement.querySelector( + '#dashboard-unit-content-2', + ) as HTMLElement; + + toggles[0].click(); + await syncView(); + + expect(toggles[0].getAttribute('aria-expanded')).toBe('true'); + expect(firstControls.classList).toContain('is-mobile-open'); + expect(firstContent.classList).toContain('is-mobile-open'); + + toggles[1].click(); + await syncView(); + + expect(toggles[0].getAttribute('aria-expanded')).toBe('false'); + expect(toggles[1].getAttribute('aria-expanded')).toBe('true'); + expect(firstControls.classList).not.toContain('is-mobile-open'); + expect(firstContent.classList).not.toContain('is-mobile-open'); + expect(secondContent.classList).toContain('is-mobile-open'); + + component.setUnitScope('active'); + await syncView(); + + expect(component.expandedMobileProjectId).toBeNull(); + expect(toggles[1].getAttribute('aria-expanded')).toBe('false'); + }); + + it('uses a wrapping grid with cards that can shrink to the viewport', async () => { + projectsSubject.next([makeProject(1, 'SIT764', true)]); + await syncView(); + + const toolbar = fixture.nativeElement.querySelector( + '[aria-label="Global dashboard filters"]', + ) as HTMLElement; + const globalSearchField = fixture.nativeElement + .querySelector('input[aria-label="Global search"]') + .closest('mat-form-field') as HTMLElement; + const layout = fixture.nativeElement.querySelector('.dashboard-units-layout') as HTMLElement; + const card = layout.querySelector('section') as HTMLElement; + + expect(Array.from(toolbar.classList)).toEqual(expect.arrayContaining(['px-4', 'sm:px-16'])); + expect(Array.from(globalSearchField.classList)).toEqual( + expect.arrayContaining(['min-w-0', 'sm:min-w-64']), + ); + expect(Array.from(layout.classList)).toEqual(expect.arrayContaining(['grid'])); + expect(Array.from(card.classList)).toEqual(expect.arrayContaining(['w-full', 'min-w-0'])); + }); + + it('combines feedback with status, grade, date, unit and task searches', () => { + const match = makeTask('Review report', 'D1', 'redo', makeDate(10), 0, 2); + match.hasFeedback = true; + const noFeedback = makeTask('Review report', 'D2', 'redo', makeDate(10), 1, 2); + noFeedback.hasFeedback = false; + const wrongGrade = makeTask('Review report', 'P1', 'redo', makeDate(10), 2, 0); + wrongGrade.hasFeedback = true; + projectsSubject.next([makeProject(1, 'SIT764', true, [match, noFeedback, wrongGrade])]); + component.setFeedbackFilter('available'); + component.setStatuses(['redo']); + component.setGrades([2]); + component.setGlobalSearch('SIT764 staff feedback'); + component.setSearch(1, 'review'); + component.setStartDate('2026-08-10'); + component.setEndDate('2026-08-10'); + expect(component.displayedUnits[0].tasks.map((task) => task.abbreviation)).toEqual(['D1']); + component.clearGlobalFilters(); + expect(component.feedbackFilter).toBe('all'); + expect(component.displayedUnits[0].tasks).toHaveLength(3); + }); + + it('distinguishes no feedback from unavailable data and searchable unread comments', () => { + const missing = makeTask('Unknown', 'U1', 'working_on_it', makeDate(10)); + const none = makeTask('None', 'N1', 'working_on_it', makeDate(10)); + none.hasFeedback = false; + const present = makeTask('Feedback', 'F1', 'redo', makeDate(10)); + present.hasFeedback = true; + present.numNewComments = 2; + projectsSubject.next([makeProject(1, 'SIT764', true, [missing, none, present])]); + component.setFeedbackFilter('unavailable'); + expect(component.displayedUnits[0].tasks.map((task) => task.abbreviation)).toEqual(['U1']); + component.setFeedbackFilter('none'); + expect(component.displayedUnits[0].tasks.map((task) => task.abbreviation)).toEqual(['N1']); + component.setFeedbackFilter('available'); + component.setGlobalSearch('unread comments'); + expect(component.displayedUnits[0].tasks.map((task) => task.abbreviation)).toEqual(['F1']); + }); + + it('preserves the active refresh button and stale tasks across failure and recovery', async () => { + projectsSubject.next([makeProject(1, 'SIT764', true)]); + const refresh: Subject = new Subject(); + projectServiceQuery.mockReturnValue(refresh); + const button = Array.from( + fixture.nativeElement.querySelectorAll('button') as NodeListOf, + ).find((item) => item.textContent.includes('Refresh active units')); + button.focus(); + button.click(); + button.click(); + await syncView(); + expect(projectServiceQuery).toHaveBeenCalledTimes(1); + expect(button.getAttribute('aria-disabled')).toBe('true'); + refresh.error(new Error('private response body')); + await syncView(); + expect(component.activeUnitsLoadError).toBe(true); + expect(component.displayedUnits[0].code).toBe('SIT764'); + expect(fixture.nativeElement.textContent).toContain('Showing previously loaded tasks'); + expect(fixture.nativeElement.textContent).not.toContain('private response body'); + expect(document.activeElement).toBe(button); + projectServiceQuery.mockReturnValue(of([makeProject(2, 'SIT777', true)])); + button.click(); + await syncView(); + expect(component.activeUnitsLoadError).toBe(false); + expect(component.displayedUnits[0].code).toBe('SIT777'); + expect(document.activeElement).toBe(button); + }); + + it('retries previous units once, retaining the last successful data on failure', async () => { + projectServiceQuery.mockReturnValue(of([makeProject(1, 'OLD101', false)])); + component.setUnitScope('previous'); + const refresh: Subject = new Subject(); + projectServiceQuery.mockReturnValue(refresh); + component.loadPreviousUnits(); + component.loadPreviousUnits(); + expect(projectServiceQuery).toHaveBeenCalledTimes(2); + refresh.error(new Error('offline')); + await syncView(); + expect(component.displayedUnits[0].code).toBe('OLD101'); + expect(fixture.nativeElement.textContent).toContain('Showing previously loaded tasks'); + projectServiceQuery.mockReturnValue(of([])); + component.loadPreviousUnits(); + await syncView(); + expect(component.previousUnitsLoadError).toBe(false); + expect(fixture.nativeElement.textContent).toContain('No previous units are available.'); + }); + + it('keeps tasks usable when recommendation loading fails and allows recovery', async () => { + projectsSubject.next([makeProject(1, 'SIT764', true)]); + recommendationServiceGetAll.mockReturnValue(throwError(() => new Error('offline'))); + component.retryRecommendations(); + await syncView(); + expect(component.recommendationsLoadError).toBe(true); + expect(fixture.nativeElement.textContent).toContain('Tasks remain available'); + recommendationServiceGetAll.mockReturnValue(of([])); + component.retryRecommendations(); + await syncView(); + expect(component.recommendationsLoadError).toBe(false); + }); + + it('announces results and keeps project progress and primary links in compact density', async () => { + projectsSubject.next([ + makeProject( + 1, + 'LONG101', + true, + [makeTask('A long task', 'L1', 'complete', makeDate(10))], + 'A very long synthetic project name with no clipped required information', + ), + ]); + component.density = 'compact'; + await syncView(); + expect(fixture.nativeElement.querySelector('.dashboard-compact')).not.toBeNull(); + expect(fixture.nativeElement.querySelector('p[aria-live="polite"]').textContent).toContain( + '1 task in 1 unit', + ); + expect(fixture.nativeElement.textContent).toContain('1 of 1 tasks complete'); + expect(fixture.nativeElement.textContent).toContain('Open LONG101 dashboard'); + }); + + it('allows recovery when global project loading failed before the dashboard opened', async () => { + const global = TestBed.inject(GlobalStateService); + global.projectLoadErrorSubject.next(true); + fixture.destroy(); + fixture = TestBed.createComponent(CrossDashboardComponent); + component = fixture.componentInstance; + await syncView(); + expect(component.activeUnitsLoadError).toBe(true); + expect(component.loadingActiveUnits).toBe(false); + expect(fixture.nativeElement.textContent).toContain('Active units could not be loaded'); + projectServiceQuery.mockReturnValue(of([makeProject(1, 'SIT764', true)])); + component.retryActiveUnits(); + await syncView(); + expect(component.activeUnitsLoadError).toBe(false); + expect(global.projectLoadErrorSubject.value).toBe(false); + expect(component.displayedUnits[0].code).toBe('SIT764'); + }); + + it('does not resurrect projects absent from a successful refresh when the entity cache emits', async () => { + const remaining = makeProject(1, 'CURRENT', true, [ + makeTask('Current', 'P1', 'redo', makeDate(10)), + ]); + const removed = makeProject(2, 'REMOVED', true); + projectsSubject.next([remaining, removed]); + projectServiceQuery.mockReturnValue(of([remaining])); + component.retryActiveUnits(); + projectsSubject.next([remaining, removed]); + taskStatusSubject.next(remaining.tasks[0]); + await syncView(); + expect(component.displayedUnits.map((unit) => unit.code)).toEqual(['CURRENT']); + }); +}); diff --git a/src/app/dashboard/f-cross-dashboard.component.ts b/src/app/dashboard/f-cross-dashboard.component.ts new file mode 100644 index 0000000000..1578ee6e89 --- /dev/null +++ b/src/app/dashboard/f-cross-dashboard.component.ts @@ -0,0 +1,871 @@ +import {EntityCache} from 'ngx-entity-service'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + DestroyRef, + OnInit, +} from '@angular/core'; +import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; +import {ActivatedRoute, Router} from '@angular/router'; +import {Subject, catchError, debounceTime, filter, map, merge, of, switchMap, tap} from 'rxjs'; +import {GlobalStateService} from 'src/app/projects/states/index/global-state.service'; +import {Grade} from '../api/models/grade'; +import {Project} from '../api/models/project'; +import {Task} from '../api/models/task'; +import {TaskStatus, TaskStatusEnum} from '../api/models/task-status'; +import {ProjectService} from '../api/services/project.service'; +import { + TaskRecommendation, + TaskRecommendationService, +} from '../api/services/task-recommendation.service'; +import {TaskService} from '../api/services/task.service'; +import {DashboardTask, getDueDateWarning} from './list-item/dashboard-list-item.component'; + +type UnitScope = 'active' | 'previous' | 'all'; +type FeedbackFilter = 'all' | 'available' | 'none' | 'unavailable'; + +enum Filter { + HideCompleted = 'Hide Completed', +} + +enum SortMode { + Recommended = 'Recommended', + SubmissionDate = 'Due Date', + Default = 'Default', +} + +const completedTypes: readonly TaskStatusEnum[] = ['complete']; +const finalTypes: readonly TaskStatusEnum[] = TaskStatus.FINAL_STATUSES; + +const displayedDueDateFormatter = new Intl.DateTimeFormat('en-AU', { + weekday: 'long', + day: 'numeric', + month: 'long', +}); + +const mobileDueDateFormatter = new Intl.DateTimeFormat('en-AU', { + day: 'numeric', + month: 'short', +}); + +type MobileUnitSummary = { + taskCountLabel: string; + deadlineLabel: string; + hasDeadlineWarning: boolean; +}; + +type DashboardUnit = { + projectId: number; + code: string; + name: string; + tasks: DashboardTask[]; + gradeSummaries: GradeCompletionSummary[]; + mobileSummary: MobileUnitSummary; + isPrevious: boolean; + progressLabel: string; +}; + +type GradeCompletionSummary = { + targetGrade: number; + label: string; + completed: number; + total: number; + percentage: number | null; +}; + +@Component({ + selector: 'f-cross-dashboard', + templateUrl: './f-cross-dashboard.component.html', + styleUrl: './f-cross-dashboard.component.scss', + changeDetection: ChangeDetectionStrategy.Eager, + standalone: false, +}) +export class CrossDashboardComponent implements OnInit { + activeUnits: DashboardUnit[] = []; + previousUnits: DashboardUnit[] = []; + + unitScope: UnitScope = 'active'; + + globalSearchTerm = ''; + selectedStatuses: TaskStatusEnum[] = []; + selectedGrades: number[] = []; + feedbackFilter: FeedbackFilter = 'all'; + density: 'comfortable' | 'compact' = 'comfortable'; + loadingActiveUnits = true; + activeUnitsLoadError = false; + loadingRecommendations = false; + recommendationsLoadError = false; + + readonly statusOptions = TaskStatus.STATUS_KEYS.flatMap((value) => { + const label = TaskStatus.STATUS_LABELS.get(value); + return label ? [{value, label}] : []; + }); + readonly gradeOptions = Grade.PASS_RANGE.map((value) => ({ + value, + label: Grade.GRADES[value], + })); + + startDate = ''; + endDate = ''; + + previousUnitsLoaded = false; + loadingPreviousUnits = false; + previousUnitsLoadError = false; + + filterOptions = Object.values(Filter); + sortOptions = Object.values(SortMode); + unitsProcessed: DashboardUnit[] = []; + expandedMobileProjectId: number | null = null; + mobileGlobalFiltersExpanded = false; + + private readonly previousProjectsCache: EntityCache = new EntityCache(); + private filters: Map = new Map(); + private sorting: Map = new Map(); + private searchTerms: Map = new Map(); + private recommendationScores: Map = new Map(); + private readonly recommendationRefresh: Subject = new Subject(); + private refreshedActiveProjectIds: Set | null = null; + + constructor( + private globalStateService: GlobalStateService, + private projectService: ProjectService, + private taskRecommendationService: TaskRecommendationService, + private taskService: TaskService, + private changeDetectorRef: ChangeDetectorRef, + private destroyRef: DestroyRef, + private route: ActivatedRoute, + private router: Router, + ) {} + + ngOnInit(): void { + this.globalStateService.onLoad(() => { + this.loadingActiveUnits = false; + this.activeUnitsLoadError = this.globalStateService.projectLoadErrorSubject.value; + this.route.queryParamMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => { + const requested = params.get('scope'); + const scope: UnitScope = + requested === 'previous' || requested === 'all' ? requested : 'active'; + if (scope !== this.unitScope) { + this.applyUnitScope(scope); + } + }); + + const projectChanges = this.globalStateService.currentUserProjects.values.pipe( + tap((projects) => this.refreshActiveUnits(projects)), + map(() => undefined), + ); + const activeTaskStatusChanges = this.taskService.taskStatusUpdated$.pipe( + filter((task) => this.isTaskInActiveProject(task)), + debounceTime(0), + tap(() => + this.refreshActiveUnits(this.globalStateService.currentUserProjects.currentValues), + ), + map(() => undefined), + ); + + merge(projectChanges, activeTaskStatusChanges, this.recommendationRefresh) + .pipe( + tap(() => { + this.loadingRecommendations = true; + this.recommendationsLoadError = false; + }), + switchMap(() => + this.taskRecommendationService.getAll().pipe( + catchError(() => { + this.recommendationsLoadError = true; + return of(null); + }), + ), + ), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe((recommendations) => { + this.loadingRecommendations = false; + if (recommendations !== null) { + this.setRecommendationScores(recommendations); + } + this.processTasks(); + }); + }); + } + + private refreshActiveUnits(projects: readonly Project[]): void { + const activeProjects = projects.filter( + (project) => + project.unit.isActive && + (this.refreshedActiveProjectIds === null || this.refreshedActiveProjectIds.has(project.id)), + ); + this.activeUnits = this.mapProjects(activeProjects); + this.processTasks(); + } + + private isTaskInActiveProject(task: Task): boolean { + const projectId = task.project?.id; + + return this.globalStateService.currentUserProjects.currentValues.some( + (project) => project.id === projectId && project.unit.isActive, + ); + } + + get displayedUnits(): DashboardUnit[] { + return this.unitsProcessed; + } + + get resultCountLabel(): string { + const count = this.displayedUnits.reduce((total, unit) => total + unit.tasks.length, 0); + return `${count} ${count === 1 ? 'task' : 'tasks'} in ${this.displayedUnits.length} ${ + this.displayedUnits.length === 1 ? 'unit' : 'units' + }`; + } + + setFeedbackFilter(value: FeedbackFilter): void { + this.feedbackFilter = value; + this.processTasks(); + } + + retryRecommendations(): void { + if (!this.loadingRecommendations) { + this.recommendationRefresh.next(); + } + } + + retryActiveUnits(): void { + if (this.loadingActiveUnits) { + return; + } + this.loadingActiveUnits = true; + this.activeUnitsLoadError = false; + this.projectService + .fetchAll(undefined, { + cache: this.globalStateService.currentUserProjects, + params: {include_inactive: false, include_task_definitions: true}, + }) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (projects: Project[]) => { + this.loadingActiveUnits = false; + this.refreshedActiveProjectIds = new Set(projects.map((project) => project.id)); + this.globalStateService.projectLoadErrorSubject.next(false); + this.refreshActiveUnits(projects); + }, + error: () => { + this.loadingActiveUnits = false; + this.activeUnitsLoadError = true; + this.changeDetectorRef.markForCheck(); + }, + }); + } + + get isDateRangeInvalid(): boolean { + return !!this.startDate && !!this.endDate && this.startDate > this.endDate; + } + + get isDateFilterActive(): boolean { + return !this.isDateRangeInvalid && (!!this.startDate || !!this.endDate); + } + + get hasGlobalTaskCriteria(): boolean { + return ( + this.normaliseSearchText(this.globalSearchTerm).length > 0 || + this.selectedStatuses.length > 0 || + this.selectedGrades.length > 0 || + this.feedbackFilter !== 'all' + ); + } + + get hasGlobalToolbarChanges(): boolean { + return ( + this.unitScope !== 'active' || + this.globalSearchTerm.length > 0 || + this.selectedStatuses.length > 0 || + this.selectedGrades.length > 0 || + this.feedbackFilter !== 'all' || + !!this.startDate || + !!this.endDate + ); + } + + get mobileSecondaryFilterCount(): number { + return ( + this.selectedStatuses.length + + this.selectedGrades.length + + Number(this.feedbackFilter !== 'all') + + Number(!!this.startDate) + + Number(!!this.endDate) + ); + } + + setUnitScope(scope: UnitScope): void { + this.applyUnitScope(scope); + void this.router.navigate([], { + relativeTo: this.route, + queryParams: {scope: scope === 'active' ? null : scope}, + queryParamsHandling: 'merge', + preserveFragment: true, + replaceUrl: true, + }); + } + + private applyUnitScope(scope: UnitScope): void { + this.unitScope = scope; + this.expandedMobileProjectId = null; + this.processTasks(); + + const needsPreviousUnits = scope === 'previous' || scope === 'all'; + + if (needsPreviousUnits && !this.previousUnitsLoaded && !this.loadingPreviousUnits) { + this.loadPreviousUnits(); + } + } + + setGlobalSearch(value: string): void { + this.globalSearchTerm = value ?? ''; + this.processTasks(); + } + + toggleMobileProject(projectId: number): void { + this.expandedMobileProjectId = this.expandedMobileProjectId === projectId ? null : projectId; + } + + toggleMobileGlobalFilters(): void { + this.mobileGlobalFiltersExpanded = !this.mobileGlobalFiltersExpanded; + } + + isMobileProjectExpanded(projectId: number): boolean { + return this.expandedMobileProjectId === projectId; + } + + setStatuses(statuses: readonly TaskStatusEnum[] | null): void { + this.selectedStatuses = [...(statuses ?? [])]; + this.processTasks(); + } + + setGrades(grades: readonly number[] | null): void { + this.selectedGrades = [...(grades ?? [])]; + this.processTasks(); + } + + clearGlobalFilters(): void { + this.globalSearchTerm = ''; + this.selectedStatuses = []; + this.selectedGrades = []; + this.feedbackFilter = 'all'; + this.startDate = ''; + this.endDate = ''; + + if (this.unitScope !== 'active') { + this.setUnitScope('active'); + return; + } + + this.processTasks(); + } + + setStartDate(value: string): void { + this.startDate = value; + this.processTasks(); + } + + setEndDate(value: string): void { + this.endDate = value; + this.processTasks(); + } + + clearDateRange(): void { + this.startDate = ''; + this.endDate = ''; + this.processTasks(); + } + + setSort(project: number, mode: SortMode): void { + this.sorting.set(project, mode); + this.processTasks(); + } + + getSort(project: number): SortMode { + return this.sorting.get(project) ?? SortMode.Recommended; + } + + toggleFilter(project: number, filter: Filter): void { + let filters = this.filters.get(project) ?? []; + if (filters.includes(filter)) { + filters = filters.filter((currentFilter) => currentFilter !== filter); + } else { + filters = [...filters, filter]; + } + this.filters.set(project, filters); + this.processTasks(); + } + + isFilterEnabled(project: number, filter: Filter): boolean { + return this.filters.get(project)?.includes(filter) === true; + } + + setSearch(project: number, value: string): void { + this.searchTerms.set(project, value ?? ''); + this.processTasks(); + } + + getSearchTerm(project: number): string { + return this.searchTerms.get(project) ?? ''; + } + + hasSearchTerm(project: number): boolean { + return this.normaliseSearchText(this.getSearchTerm(project)).length > 0; + } + + hasActiveTaskCriteria(project: number): boolean { + return ( + this.hasGlobalTaskCriteria || + this.isDateFilterActive || + this.hasSearchTerm(project) || + (this.filters.get(project)?.length ?? 0) > 0 + ); + } + + loadPreviousUnits(): void { + if (this.loadingPreviousUnits) { + return; + } + this.loadingPreviousUnits = true; + this.previousUnitsLoadError = false; + + this.projectService + .fetchAll(undefined, { + cache: this.previousProjectsCache, + params: { + include_inactive: true, + include_task_definitions: true, + }, + }) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (projects: Project[]) => { + const previousProjects = projects.filter((project) => !project.unit.isActive); + + this.previousUnits = this.mapProjects(previousProjects); + this.previousUnitsLoaded = true; + this.loadingPreviousUnits = false; + this.processTasks(); + + this.changeDetectorRef.detectChanges(); + }, + error: () => { + this.previousUnitsLoadError = true; + this.loadingPreviousUnits = false; + this.processTasks(); + + this.changeDetectorRef.detectChanges(); + }, + }); + } + + private processTasks(): void { + const units = this.getUnitsForCurrentScope(); + + this.unitsProcessed = units + .map((unit) => ({ + unit, + globallyFilteredTasks: unit.tasks.filter((task) => + this.taskMatchesGlobalCriteria(task, unit), + ), + })) + .filter( + ({globallyFilteredTasks}) => + !this.hasGlobalTaskCriteria || globallyFilteredTasks.length > 0, + ) + .map(({unit, globallyFilteredTasks}) => ({ + ...unit, + gradeSummaries: this.buildGradeSummaries(unit.tasks), + tasks: globallyFilteredTasks + .filter((task) => { + const filters = this.filters.get(unit.projectId) ?? []; + const isHiddenCompletedTask = + filters.includes(Filter.HideCompleted) && completedTypes.includes(task.status); + + return ( + !isHiddenCompletedTask && + this.taskMatchesDateRange(task) && + this.taskMatchesPerUnitSearch(task, unit) + ); + }) + .sort((a, b) => { + const sort = this.sorting.get(unit.projectId) ?? SortMode.Recommended; + + if (finalTypes.includes(a.status) && !finalTypes.includes(b.status)) { + return 1; + } + + if (!finalTypes.includes(a.status) && finalTypes.includes(b.status)) { + return -1; + } + + switch (sort) { + case SortMode.Recommended: + return this.compareRecommendations(a, b); + case SortMode.SubmissionDate: + return this.compareDueDates(a.dueDate, b.dueDate); + case SortMode.Default: + return a.weight - b.weight; + } + + return 0; + }), + })); + + this.unitsProcessed = this.unitsProcessed.map((unit) => ({ + ...unit, + mobileSummary: this.buildMobileUnitSummary(unit.tasks), + })); + + this.changeDetectorRef.markForCheck(); + } + + private setRecommendationScores(recommendations: readonly TaskRecommendation[]): void { + this.recommendationScores = new Map( + recommendations + .filter((recommendation) => Number.isFinite(recommendation.priority_score)) + .map((recommendation) => [ + this.recommendationKey(recommendation.project_id, recommendation.task_definition_id), + recommendation.priority_score, + ]), + ); + } + + private compareRecommendations(first: DashboardTask, second: DashboardTask): number { + const firstScore = this.recommendationScores.get( + this.recommendationKey(first.projectId, first.taskDefinitionId), + ); + const secondScore = this.recommendationScores.get( + this.recommendationKey(second.projectId, second.taskDefinitionId), + ); + + if (firstScore !== undefined && secondScore !== undefined) { + return secondScore - firstScore || first.weight - second.weight; + } + + if (firstScore !== undefined) { + return -1; + } + + if (secondScore !== undefined) { + return 1; + } + + return first.weight - second.weight; + } + + private recommendationKey(projectId: number, taskDefinitionId: number): string { + return `${projectId}:${taskDefinitionId}`; + } + + private taskMatchesGlobalCriteria(task: DashboardTask, unit: DashboardUnit): boolean { + const matchesStatus = + this.selectedStatuses.length === 0 || this.selectedStatuses.includes(task.status); + const matchesGrade = + this.selectedGrades.length === 0 || this.selectedGrades.includes(task.targetGrade); + + const matchesFeedback = + this.feedbackFilter === 'all' || + (this.feedbackFilter === 'available' && task.hasFeedback === true) || + (this.feedbackFilter === 'none' && task.hasFeedback === false) || + (this.feedbackFilter === 'unavailable' && task.hasFeedback == null); + + return ( + matchesStatus && matchesGrade && matchesFeedback && this.taskMatchesGlobalSearch(task, unit) + ); + } + + private buildGradeSummaries(tasks: readonly DashboardTask[]): GradeCompletionSummary[] { + return this.selectedGrades.map((targetGrade) => { + const tasksInGrade = tasks.filter((task) => task.targetGrade === targetGrade); + const completed = tasksInGrade.filter((task) => task.status === 'complete').length; + const total = tasksInGrade.length; + + return { + targetGrade, + label: + tasksInGrade[0]?.targetGradeLabel ?? + this.gradeOptions.find((option) => option.value === targetGrade)?.label ?? + '', + completed, + total, + percentage: total === 0 ? null : Math.round((completed / total) * 100), + }; + }); + } + + private buildMobileUnitSummary(tasks: readonly DashboardTask[]): MobileUnitSummary { + const taskCount = tasks.length; + const nextDueTask = tasks + .filter( + (task) => + task.showDueWarning && + !finalTypes.includes(task.status) && + task.dueDate instanceof Date && + !Number.isNaN(task.dueDate.getTime()), + ) + .sort((first, second) => this.compareDueDates(first.dueDate, second.dueDate))[0]; + + if (!nextDueTask || !(nextDueTask.dueDate instanceof Date)) { + return { + taskCountLabel: `${taskCount} ${taskCount === 1 ? 'task' : 'tasks'}`, + deadlineLabel: 'No upcoming deadlines', + hasDeadlineWarning: false, + }; + } + + const warning = getDueDateWarning(nextDueTask.dueDate, true); + + return { + taskCountLabel: `${taskCount} ${taskCount === 1 ? 'task' : 'tasks'}`, + deadlineLabel: warning + ? `${warning.label}: ${nextDueTask.abbreviation}` + : `Next due ${mobileDueDateFormatter.format(nextDueTask.dueDate)}: ${ + nextDueTask.abbreviation + }`, + hasDeadlineWarning: warning !== null, + }; + } + + private taskMatchesDateRange(task: DashboardTask): boolean { + if (!this.isDateFilterActive) { + return true; + } + + const taskDate = this.formatDateAsIso(task.dueDate); + + if (!taskDate) { + return false; + } + + if (this.startDate && taskDate < this.startDate) { + return false; + } + + if (this.endDate && taskDate > this.endDate) { + return false; + } + + return true; + } + + private compareDueDates( + firstDate: Date | null | undefined, + secondDate: Date | null | undefined, + ): number { + const firstTime = + firstDate instanceof Date && !Number.isNaN(firstDate.getTime()) ? firstDate.getTime() : null; + + const secondTime = + secondDate instanceof Date && !Number.isNaN(secondDate.getTime()) + ? secondDate.getTime() + : null; + + if (firstTime === null && secondTime === null) { + return 0; + } + + if (firstTime === null) { + return 1; + } + + if (secondTime === null) { + return -1; + } + + return firstTime - secondTime; + } + + private taskMatchesGlobalSearch(task: DashboardTask, unit: DashboardUnit): boolean { + return this.taskMatchesSearchText(task, this.globalSearchTerm, [ + unit.code, + unit.name, + task.title, + task.abbreviation, + task.statusLabel, + task.targetGradeLabel, + ]); + } + + private taskMatchesPerUnitSearch(task: DashboardTask, unit: DashboardUnit): boolean { + return this.taskMatchesSearchText(task, this.getSearchTerm(unit.projectId), [ + task.title, + task.subtitle, + task.description, + task.abbreviation, + task.statusLabel, + unit.code, + ]); + } + + private taskMatchesSearchText( + task: DashboardTask, + rawSearchTerm: string, + searchableValues: readonly string[], + ): boolean { + const searchTerm = this.normaliseSearchText(rawSearchTerm); + + if (!searchTerm) { + return true; + } + + const {numericDates, remainingText} = this.extractNumericDateSearches(rawSearchTerm); + const taskDate = this.formatDateAsIso(task.dueDate); + + if (numericDates.some((date) => date !== taskDate)) { + return false; + } + + const remainingSearchTerm = this.normaliseSearchText(remainingText); + + if (!remainingSearchTerm) { + return numericDates.length > 0; + } + + const searchableText = this.normaliseSearchText( + [ + ...searchableValues, + this.feedbackSearchText(task), + this.formatDateForSearch(task.dueDate), + ].join(' '), + ); + + return remainingSearchTerm.split(' ').every((term) => searchableText.includes(term)); + } + + private feedbackSearchText(task: DashboardTask): string { + const feedback = + task.hasFeedback === true + ? 'staff feedback available' + : task.hasFeedback === false + ? 'no staff feedback' + : 'feedback unavailable'; + return task.comments > 0 ? `${feedback} unread comments` : feedback; + } + + private extractNumericDateSearches(value: string): { + numericDates: string[]; + remainingText: string; + } { + const numericDates: string[] = []; + let remainingText = value; + + remainingText = remainingText.replace( + /\b(\d{4})-(\d{1,2})-(\d{1,2})\b/g, + (_match: string, year: string, month: string, day: string) => { + numericDates.push(this.normaliseNumericDate(year, month, day)); + return ' '; + }, + ); + + remainingText = remainingText.replace( + /\b(\d{1,2})\/(\d{1,2})\/(\d{4})\b/g, + (_match: string, day: string, month: string, year: string) => { + numericDates.push(this.normaliseNumericDate(year, month, day)); + return ' '; + }, + ); + + return {numericDates, remainingText}; + } + + private normaliseNumericDate(year: string, month: string, day: string): string { + return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`; + } + + private normaliseSearchText(value: string): string { + return value + .toLocaleLowerCase('en-AU') + .replace(/[^\p{L}\p{N}]+/gu, ' ') + .trim(); + } + + private formatDateAsIso(date: Date | null | undefined): string { + if (!(date instanceof Date) || Number.isNaN(date.getTime())) { + return ''; + } + + return this.normaliseNumericDate( + String(date.getFullYear()), + String(date.getMonth() + 1), + String(date.getDate()), + ); + } + + private formatDateForSearch(date: Date | null | undefined): string { + if (!(date instanceof Date) || Number.isNaN(date.getTime())) { + return ''; + } + + const isoDate = this.formatDateAsIso(date); + const [year, month, day] = isoDate.split('-'); + + return [displayedDueDateFormatter.format(date), `${day}/${month}/${year}`, isoDate].join(' '); + } + + private getUnitsForCurrentScope(): DashboardUnit[] { + if (this.unitScope === 'previous') { + return this.previousUnits; + } + + if (this.unitScope === 'all') { + return [...this.activeUnits, ...this.previousUnits]; + } + + return this.activeUnits; + } + + private mapProjects(projects: readonly Project[]): DashboardUnit[] { + return projects.map((project) => { + project.calcTopTasks(); + const unit = project.unit; + + return { + projectId: project.id, + code: unit.code, + name: unit.name, + // The cross-unit dashboard is an authorised-task view, not a target-grade plan. + // `activeTasks()` excludes definitions above the student's current target grade, + // even though those tasks are returned by the API and remain available to them. + tasks: this.mapTasks(project.tasks, project.id, unit.code), + gradeSummaries: [], + mobileSummary: { + taskCountLabel: '0 tasks', + deadlineLabel: 'No upcoming deadlines', + hasDeadlineWarning: false, + }, + isPrevious: !unit.isActive, + progressLabel: `${project.tasks.filter((task) => task.status === 'complete').length} of ${project.tasks.length} tasks complete`, + }; + }); + } + + private mapTasks(tasks: readonly Task[], projectId: number, unitCode: string): DashboardTask[] { + return tasks.map((task) => { + const def = task.definition; + + return { + taskDefinitionId: def.id, + title: def.name, + subtitle: `${def.abbreviation} - ${def.targetGradeText} Task`, + statusLabel: TaskStatus.STATUS_LABELS.get(task.status), + abbreviation: def.abbreviation, + color: TaskStatus.STATUS_COLORS.get(task.status), + comments: task.numNewComments ?? 0, + hasFeedback: typeof task.hasFeedback === 'boolean' ? task.hasFeedback : null, + status: task.status, + targetGrade: def.targetGrade, + targetGradeLabel: def.targetGradeText, + weight: task.topWeight, + projectId, + description: def.description, + taskDef: def, + unitCode, + dueDate: task.localDueDate(), + showDueWarning: !task.inSubmittedState(), + }; + }); + } +} diff --git a/src/app/dashboard/f-cross-dashboard.route.spec.ts b/src/app/dashboard/f-cross-dashboard.route.spec.ts new file mode 100644 index 0000000000..4a82514e51 --- /dev/null +++ b/src/app/dashboard/f-cross-dashboard.route.spec.ts @@ -0,0 +1,165 @@ +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {TestBed} from '@angular/core/testing'; +import {Router, provideRouter} from '@angular/router'; +import {RouterTestingHarness} from '@angular/router/testing'; +import {BehaviorSubject, EMPTY, of} from 'rxjs'; +import {AuthenticationService} from '../api/services/authentication.service'; +import {ProjectService} from '../api/services/project.service'; +import {TaskRecommendationService} from '../api/services/task-recommendation.service'; +import {TaskService} from '../api/services/task.service'; +import {UserService} from '../api/services/user.service'; +import {routes} from '../app.routes'; +import {GlobalStateService} from '../projects/states/index/global-state.service'; +import {CrossDashboardComponent} from './f-cross-dashboard.component'; + +describe('Cross-Project Dashboard route (/dashboard)', () => { + const userServiceMock = { + currentUser: {role: 'Student'}, + }; + + const isAuthorisedMock = vi.fn((roleWhitelist: string[], role?: string) => { + return role !== undefined && roleWhitelist.includes(role); + }); + + const globalStateMock = { + isLoadingSubject: new BehaviorSubject(false), + projectLoadErrorSubject: new BehaviorSubject(false), + loadedUnitRoles: {currentValues: []}, + currentUserProjects: {values: of([])}, + onLoad: (run: () => void) => run(), + }; + + const projectServiceMock = { + query: vi.fn().mockReturnValue(of([])), + fetchAll: vi.fn().mockReturnValue(of([])), + }; + + beforeEach(() => { + userServiceMock.currentUser.role = 'Student'; + isAuthorisedMock.mockClear(); + projectServiceMock.query.mockClear(); + projectServiceMock.fetchAll.mockClear(); + + TestBed.configureTestingModule({ + providers: [ + provideRouter(routes), + { + provide: AuthenticationService, + useValue: {isAuthorised: isAuthorisedMock}, + }, + { + provide: UserService, + useValue: userServiceMock, + }, + { + provide: GlobalStateService, + useValue: globalStateMock, + }, + { + provide: ProjectService, + useValue: projectServiceMock, + }, + { + provide: TaskRecommendationService, + useValue: {getAll: () => of([])}, + }, + { + provide: TaskService, + useValue: {taskStatusUpdated$: EMPTY}, + }, + ], + }).overrideComponent(CrossDashboardComponent, { + // Route/guard and query-state integration is covered here. The component + // suite renders the actual template with its Angular Material imports. + set: {template: '

Cross-project dashboard

', styles: []}, + }); + }); + + it('AC1: allows a Student to navigate directly to /dashboard', async () => { + const harness = await RouterTestingHarness.create('/dashboard'); + const router = TestBed.inject(Router); + + expect(router.url).toBe('/dashboard'); + expect(harness.routeNativeElement).toBeTruthy(); + expect(isAuthorisedMock).toHaveBeenCalledTimes(1); + expect(isAuthorisedMock).toHaveBeenCalledWith(['Student'], 'Student'); + }); + + it('AC3: redirects a non-whitelisted Tutor to /unauthorised', async () => { + userServiceMock.currentUser.role = 'Tutor'; + + const harness = await RouterTestingHarness.create('/dashboard'); + const router = TestBed.inject(Router); + + expect(router.url).toBe('/unauthorised'); + expect(harness.routeNativeElement).toBeTruthy(); + expect(isAuthorisedMock).toHaveBeenCalledTimes(1); + expect(isAuthorisedMock).toHaveBeenCalledWith(['Student'], 'Tutor'); + }); + + // RouterTestingHarness performs client-side navigation. It does not restart + // the browser or Angular application, so real refresh coverage belongs in + // a future browser-level end-to-end test. + + it.each(['previous', 'all'] as const)( + 'AC4: restores %s unit scope on direct navigation', + async (scope) => { + const harness = await RouterTestingHarness.create(); + const component = await harness.navigateByUrl( + `/dashboard?scope=${scope}`, + CrossDashboardComponent, + ); + expect(component.unitScope).toBe(scope); + expect(projectServiceMock.fetchAll).toHaveBeenCalledTimes(1); + }, + ); + + it.each(['/dashboard', '/dashboard?scope=invalid', '/dashboard?scope=active'])( + 'defaults to active for %s without loading previous units', + async (url) => { + const harness = await RouterTestingHarness.create(); + const component = await harness.navigateByUrl(url, CrossDashboardComponent); + expect(component.unitScope).toBe('active'); + expect(projectServiceMock.fetchAll).not.toHaveBeenCalled(); + }, + ); + + it('follows query-only navigation on a reused dashboard without reloading previous units', async () => { + const harness = await RouterTestingHarness.create(); + const component = await harness.navigateByUrl( + '/dashboard?scope=previous', + CrossDashboardComponent, + ); + const next = await harness.navigateByUrl('/dashboard?scope=all', CrossDashboardComponent); + expect(next).toBe(component); + expect(component.unitScope).toBe('all'); + await harness.navigateByUrl('/dashboard', CrossDashboardComponent); + expect(component.unitScope).toBe('active'); + expect(projectServiceMock.fetchAll).toHaveBeenCalledTimes(1); + }); + + it('keeps the open phone card when unrelated query parameters change', async () => { + const harness = await RouterTestingHarness.create(); + const component = await harness.navigateByUrl('/dashboard?scope=all', CrossDashboardComponent); + component.expandedMobileProjectId = 42; + await harness.navigateByUrl('/dashboard?scope=all&tab=tasks', CrossDashboardComponent); + expect(component.expandedMobileProjectId).toBe(42); + expect(projectServiceMock.fetchAll).toHaveBeenCalledTimes(1); + }); + + it('writes scope changes while preserving unrelated query parameters and the fragment', async () => { + const harness = await RouterTestingHarness.create(); + const component = await harness.navigateByUrl( + '/dashboard?tab=tasks#units', + CrossDashboardComponent, + ); + const router = TestBed.inject(Router); + component.setUnitScope('previous'); + await harness.fixture.whenStable(); + expect(router.url).toBe('/dashboard?tab=tasks&scope=previous#units'); + component.clearGlobalFilters(); + await harness.fixture.whenStable(); + expect(router.url).toBe('/dashboard?tab=tasks#units'); + expect(component.unitScope).toBe('active'); + }); +}); diff --git a/src/app/dashboard/list-item/dashboard-list-item.component.html b/src/app/dashboard/list-item/dashboard-list-item.component.html new file mode 100644 index 0000000000..055087cb02 --- /dev/null +++ b/src/app/dashboard/list-item/dashboard-list-item.component.html @@ -0,0 +1,77 @@ +
+
+
+

+ + {{ task.title }} + +

+
+

{{ task.subtitle }}

+ @if (dueDateWarning; as warning) { + + + {{ warning.label }} + + } + @if (task.hasFeedback === true) { + + + Staff feedback + + } @else if (task.hasFeedback === null) { + Feedback unavailable + } +
+

Status: {{ task.statusLabel }}

+
+
+ @if (!isExpanded && task.comments > 0) { +
+ +
+ } + +
+
+
+ @if (isExpanded) { + + } +
diff --git a/src/app/dashboard/list-item/dashboard-list-item.component.spec.ts b/src/app/dashboard/list-item/dashboard-list-item.component.spec.ts new file mode 100644 index 0000000000..8840252b83 --- /dev/null +++ b/src/app/dashboard/list-item/dashboard-list-item.component.spec.ts @@ -0,0 +1,227 @@ +import {beforeEach, describe, expect, it} from 'vitest'; +import {NO_ERRORS_SCHEMA} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {MatButtonModule} from '@angular/material/button'; +import {MatIconModule} from '@angular/material/icon'; +import {RouterLink, provideRouter} from '@angular/router'; +import {TaskDefinition} from '../../api/models/task-definition'; +import { + DashboardListItemComponent, + DashboardTask, + getDueDateWarning, +} from './dashboard-list-item.component'; + +describe('getDueDateWarning', () => { + const now = new Date('2026-08-23T00:00:00.000Z'); + const day = 24 * 60 * 60 * 1000; + + it.each([ + {offset: -1, state: 'overdue', label: 'Overdue', icon: 'error'}, + { + offset: day, + state: 'within24Hours', + label: 'Due within 24 hours', + icon: 'schedule', + }, + { + offset: day + 1, + state: 'within3Days', + label: 'Due within 3 days', + icon: 'warning', + }, + { + offset: 3 * day, + state: 'within3Days', + label: 'Due within 3 days', + icon: 'warning', + }, + { + offset: 3 * day + 1, + state: 'within7Days', + label: 'Due within 7 days', + icon: 'event', + }, + { + offset: 7 * day, + state: 'within7Days', + label: 'Due within 7 days', + icon: 'event', + }, + ])('returns $state at the expected boundary', ({offset, state, label, icon}) => { + const dueDate = new Date(now.getTime() + offset); + + expect(getDueDateWarning(dueDate, true, now)).toEqual({state, label, icon}); + }); + + it('treats a task due now as due within 24 hours', () => { + expect(getDueDateWarning(now, true, now)?.state).toBe('within24Hours'); + }); + + it('does not warn more than seven days before the due date', () => { + const dueDate = new Date(now.getTime() + 7 * day + 1); + + expect(getDueDateWarning(dueDate, true, now)).toBeNull(); + }); + + it('does not warn for a submitted task', () => { + const overdueDate = new Date(now.getTime() - day); + + expect(getDueDateWarning(overdueDate, false, now)).toBeNull(); + }); + + it.each([null, undefined, new Date('invalid')])( + 'ignores a missing or invalid date', + (dueDate) => { + expect(getDueDateWarning(dueDate, true, now)).toBeNull(); + }, + ); +}); + +describe('DashboardListItemComponent', () => { + let component: DashboardListItemComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [DashboardListItemComponent], + imports: [MatButtonModule, MatIconModule, RouterLink], + providers: [provideRouter([])], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(DashboardListItemComponent); + component = fixture.componentInstance; + component.task = { + taskDefinitionId: 10, + title: 'Security Review', + subtitle: '1.1P - Pass Task', + statusLabel: 'Resubmit', + abbreviation: '1.1P', + color: '#123456', + comments: 0, + hasFeedback: false, + status: 'fix_and_resubmit', + targetGrade: 0, + targetGradeLabel: 'Pass', + weight: 0, + projectId: 1, + description: 'Review the security findings.', + taskDef: {} as TaskDefinition, + unitCode: 'SIT764', + dueDate: new Date(2026, 7, 12), + showDueWarning: false, + } satisfies DashboardTask; + fixture.detectChanges(); + }); + + it('shows a textual status without requiring the colour indicator', () => { + expect(fixture.nativeElement.textContent).toContain('Status: Resubmit'); + }); + + it('uses a labelled native button with expanded state for task details', () => { + const button = fixture.nativeElement.querySelector('button') as HTMLButtonElement; + + expect(button.getAttribute('aria-label')).toBe('Expand Security Review details'); + expect(button.getAttribute('aria-expanded')).toBe('false'); + + button.click(); + fixture.detectChanges(); + + expect(button.getAttribute('aria-label')).toBe('Collapse Security Review details'); + expect(button.getAttribute('aria-expanded')).toBe('true'); + }); + + it('uses a keyboard-focusable link for the task title', () => { + const titleLink = fixture.nativeElement.querySelector('h4 a') as HTMLAnchorElement; + + expect(titleLink).not.toBeNull(); + expect(titleLink.textContent.trim()).toBe('Security Review'); + expect(titleLink.classList).toContain('focus-visible:outline-2'); + expect(titleLink.getAttribute('href')).toBe('/projects/1/dashboard/1.1P'); + }); + + it('shows the staff feedback indicator only when feedback is present', () => { + expect(fixture.nativeElement.textContent).not.toContain('Staff feedback'); + + fixture.componentRef.setInput('task', { + ...component.task, + hasFeedback: true, + }); + fixture.detectChanges(); + + const feedbackBadge = fixture.nativeElement.querySelector('.bg-green-100') as HTMLElement; + const feedbackIcon = feedbackBadge.querySelector('mat-icon') as HTMLElement; + + expect(feedbackBadge).not.toBeNull(); + expect(feedbackBadge.textContent).toContain('Staff feedback'); + expect(feedbackIcon.textContent.trim()).toBe('feedback'); + expect(feedbackIcon.getAttribute('aria-hidden')).toBe('true'); + }); + + it('renders warning text and an icon alongside the separate status colour', () => { + fixture.componentRef.setInput('task', { + taskDefinitionId: 10, + title: 'Accessible task', + subtitle: '1.1P - Pass Task', + statusLabel: 'Not Started', + abbreviation: '1.1P', + color: '#123456', + comments: 0, + hasFeedback: false, + status: 'not_started', + targetGrade: 0, + targetGradeLabel: 'Pass', + weight: 0, + projectId: 1, + description: 'Description', + taskDef: {} as DashboardTask['taskDef'], + unitCode: 'COS10001', + dueDate: new Date(Date.now() + 12 * 60 * 60 * 1000), + showDueWarning: true, + } satisfies DashboardTask); + + fixture.detectChanges(); + + expect(component.dueDateWarning?.state).toBe('within24Hours'); + + const badge = fixture.nativeElement.querySelector('.bg-orange-100') as HTMLElement; + const icon = badge.querySelector('mat-icon') as HTMLElement; + const statusStrip = fixture.nativeElement.firstElementChild.firstElementChild as HTMLElement; + + expect(badge.textContent).toContain('Due within 24 hours'); + expect(icon.textContent.trim()).toBe('schedule'); + expect(icon.getAttribute('aria-hidden')).toBe('true'); + expect(icon.classList).toContain('!leading-none'); + expect(statusStrip.style.backgroundColor).toBe('rgb(18, 52, 86)'); + expect(fixture.nativeElement.textContent).toContain('Status: Not Started'); + }); + + it('opens the feedback destination even when there are no unread comments', () => { + fixture.componentRef.setInput('task', {...component.task, hasFeedback: true, comments: 0}); + fixture.detectChanges(); + const link = fixture.nativeElement.querySelector( + 'a[aria-label="Open staff feedback for Security Review"]', + ); + expect(link.getAttribute('href')).toBe('/projects/1/dashboard/1.1P/feedback'); + }); + + it('shows missing feedback metadata without claiming there is no feedback', () => { + fixture.componentRef.setInput('task', {...component.task, hasFeedback: null}); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain('Feedback unavailable'); + expect(fixture.nativeElement.querySelector('a[aria-label^="Open staff feedback"]')).toBeNull(); + }); + + it('names the unread count and uses the accessible shared badge colour', () => { + fixture.componentRef.setInput('task', {...component.task, comments: 3, hasFeedback: true}); + fixture.detectChanges(); + const badge = fixture.nativeElement.querySelector('[role="status"]'); + expect(badge.getAttribute('aria-label')).toBe('3 unread comments on Security Review'); + expect(badge.classList.contains('bg-formatif-blue')).toBe(true); + expect(fixture.nativeElement.textContent).not.toContain('New feedback'); + }); + + it('does not announce an unread badge for a zero count', () => { + expect(fixture.nativeElement.querySelector('[aria-label*="unread comments"]')).toBeNull(); + }); +}); diff --git a/src/app/dashboard/list-item/dashboard-list-item.component.ts b/src/app/dashboard/list-item/dashboard-list-item.component.ts new file mode 100644 index 0000000000..ee16af368f --- /dev/null +++ b/src/app/dashboard/list-item/dashboard-list-item.component.ts @@ -0,0 +1,77 @@ +import {Component, Input} from '@angular/core'; +import {TaskDefinition} from '../../api/models/task-definition'; +import {TaskStatusEnum} from '../../api/models/task-status'; + +export type DueDateWarning = { + state: 'overdue' | 'within24Hours' | 'within3Days' | 'within7Days'; + label: string; + icon: string; +}; + +const DAY_IN_MILLISECONDS = 24 * 60 * 60 * 1000; + +export function getDueDateWarning( + dueDate: Date | null | undefined, + showWarning: boolean, + now: Date = new Date(), +): DueDateWarning | null { + if (!showWarning || !(dueDate instanceof Date) || Number.isNaN(dueDate.getTime())) { + return null; + } + + const millisecondsUntilDue = dueDate.getTime() - now.getTime(); + + if (millisecondsUntilDue < 0) { + return {state: 'overdue', label: 'Overdue', icon: 'error'}; + } + + if (millisecondsUntilDue <= DAY_IN_MILLISECONDS) { + return {state: 'within24Hours', label: 'Due within 24 hours', icon: 'schedule'}; + } + + if (millisecondsUntilDue <= 3 * DAY_IN_MILLISECONDS) { + return {state: 'within3Days', label: 'Due within 3 days', icon: 'warning'}; + } + + if (millisecondsUntilDue <= 7 * DAY_IN_MILLISECONDS) { + return {state: 'within7Days', label: 'Due within 7 days', icon: 'event'}; + } + + return null; +} + +export type DashboardTask = { + taskDefinitionId: number; + title: string; + subtitle: string; + statusLabel: string; + abbreviation: string; + color: string; + comments: number; + hasFeedback: boolean | null; + status: TaskStatusEnum; + targetGrade: number; + targetGradeLabel: string; + weight: number; + projectId: number; + description: string; + taskDef: TaskDefinition; + unitCode: string; + dueDate: Date | null | undefined; + showDueWarning: boolean; +}; + +@Component({ + selector: 'f-dashboard-list-item', + standalone: false, + templateUrl: './dashboard-list-item.component.html', +}) +export class DashboardListItemComponent { + @Input() task: DashboardTask; + + isExpanded = false; + + get dueDateWarning(): DueDateWarning | null { + return getDueDateWarning(this.task?.dueDate, this.task?.showDueWarning ?? false); + } +} diff --git a/src/app/dashboard/list-item/expanded-list-item/expanded-list-item.component.html b/src/app/dashboard/list-item/expanded-list-item/expanded-list-item.component.html new file mode 100644 index 0000000000..a09d738599 --- /dev/null +++ b/src/app/dashboard/list-item/expanded-list-item/expanded-list-item.component.html @@ -0,0 +1,43 @@ +
+
+
+

{{ task.statusLabel }}

+

{{ task.description }}

+

+ Complete by + {{ task.dueDate | date: 'EEEE d MMMM' }} +

+
+ + + +
+
+
diff --git a/src/app/dashboard/list-item/expanded-list-item/expanded-list-item.component.scss b/src/app/dashboard/list-item/expanded-list-item/expanded-list-item.component.scss new file mode 100644 index 0000000000..a3b7bc573e --- /dev/null +++ b/src/app/dashboard/list-item/expanded-list-item/expanded-list-item.component.scss @@ -0,0 +1,3 @@ +.mat-badge-warn { + --mat-badge-text-color: white; +} diff --git a/src/app/dashboard/list-item/expanded-list-item/expanded-list-item.component.ts b/src/app/dashboard/list-item/expanded-list-item/expanded-list-item.component.ts new file mode 100644 index 0000000000..beaf1733c4 --- /dev/null +++ b/src/app/dashboard/list-item/expanded-list-item/expanded-list-item.component.ts @@ -0,0 +1,29 @@ +import {Component, Input} from '@angular/core'; +import {FileDownloaderService} from 'src/app/common/file-downloader/file-downloader.service'; +import {DashboardTask} from '../dashboard-list-item.component'; + +@Component({ + selector: 'f-expanded-list-item', + standalone: false, + templateUrl: './expanded-list-item.component.html', + styleUrls: ['./expanded-list-item.component.scss'], +}) +export class DashboardExpandedListItemComponent { + @Input() task: DashboardTask; + + constructor(private fileDownloader: FileDownloaderService) {} + + downloadTaskSheet() { + this.fileDownloader.downloadFile( + this.task.taskDef.getTaskPDFUrl(true), + `${this.task.unitCode}-${this.task.abbreviation}-TaskSheet.pdf`, + ); + } + + downloadResources() { + this.fileDownloader.downloadFile( + this.task.taskDef.getTaskResourcesUrl(true), + `${this.task.unitCode}-${this.task.abbreviation}-TaskResources.zip`, + ); + } +} diff --git a/src/app/projects/states/dashboard/directives/progress-dashboard/engagement-passport-card/add-engagement-dialog/add-engagement-dialog.component.html b/src/app/projects/states/dashboard/directives/progress-dashboard/engagement-passport-card/add-engagement-dialog/add-engagement-dialog.component.html index 15d8a2d71d..b4bb00d2f6 100644 --- a/src/app/projects/states/dashboard/directives/progress-dashboard/engagement-passport-card/add-engagement-dialog/add-engagement-dialog.component.html +++ b/src/app/projects/states/dashboard/directives/progress-dashboard/engagement-passport-card/add-engagement-dialog/add-engagement-dialog.component.html @@ -90,18 +90,23 @@

Add Engagement Stamp

} @if (form.controls.evidenceMode.value === 'attachment') { -
-