From 6c12a2b2a3dd9f7e5b8c495fda9aba593cc8280e Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Sun, 27 Sep 2026 19:58:36 +1000 Subject: [PATCH 1/3] feat(demo): demo mode and demo tools Brings the T2 2026 demo 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: Maple Fox --- src/app/demo/README.md | 98 +++ .../demo-controls.component.html | 342 +++++++++++ .../demo-controls.component.scss | 562 ++++++++++++++++++ .../demo-controls.component.spec.ts | 184 ++++++ .../demo-controls/demo-controls.component.ts | 174 ++++++ .../demo/demo-data-mask.interceptor.spec.ts | 91 +++ src/app/demo/demo-data-mask.interceptor.ts | 78 +++ src/app/demo/demo-meeting-links.store.spec.ts | 86 +++ src/app/demo/demo-meeting-links.store.ts | 90 +++ .../demo-mode-banner.component.html | 8 + .../demo-mode-banner.component.scss | 16 + .../demo-mode-banner.component.spec.ts | 46 ++ .../demo-mode-banner.component.ts | 22 + src/app/demo/demo-mode.store.spec.ts | 46 ++ src/app/demo/demo-mode.store.ts | 93 +++ src/app/demo/demo-tools.guard.ts | 8 + src/app/demo/demo-tools.module.ts | 36 ++ .../fixtures/peer-progress-demo.fixtures.ts | 207 +++++++ src/app/demo/fixtures/push-preview.fixture.ts | 6 + 19 files changed, 2193 insertions(+) create mode 100644 src/app/demo/README.md create mode 100644 src/app/demo/demo-controls/demo-controls.component.html create mode 100644 src/app/demo/demo-controls/demo-controls.component.scss create mode 100644 src/app/demo/demo-controls/demo-controls.component.spec.ts create mode 100644 src/app/demo/demo-controls/demo-controls.component.ts create mode 100644 src/app/demo/demo-data-mask.interceptor.spec.ts create mode 100644 src/app/demo/demo-data-mask.interceptor.ts create mode 100644 src/app/demo/demo-meeting-links.store.spec.ts create mode 100644 src/app/demo/demo-meeting-links.store.ts create mode 100644 src/app/demo/demo-mode-banner/demo-mode-banner.component.html create mode 100644 src/app/demo/demo-mode-banner/demo-mode-banner.component.scss create mode 100644 src/app/demo/demo-mode-banner/demo-mode-banner.component.spec.ts create mode 100644 src/app/demo/demo-mode-banner/demo-mode-banner.component.ts create mode 100644 src/app/demo/demo-mode.store.spec.ts create mode 100644 src/app/demo/demo-mode.store.ts create mode 100644 src/app/demo/demo-tools.guard.ts create mode 100644 src/app/demo/demo-tools.module.ts create mode 100644 src/app/demo/fixtures/peer-progress-demo.fixtures.ts create mode 100644 src/app/demo/fixtures/push-preview.fixture.ts diff --git a/src/app/demo/README.md b/src/app/demo/README.md new file mode 100644 index 0000000000..87b20e0e44 --- /dev/null +++ b/src/app/demo/README.md @@ -0,0 +1,98 @@ +# Centralised local demo mode + +This folder owns the controls and shared state for the combined local walkthrough. Unit Hub's +sample content is in `src/app/unit-hub/unit-hub-demo.fixtures.ts`. Demo tools are available only when +`environment.enableDemoTools` is true and +`environment.production` is false. They start off and persist only for the current browser tab. + +When demo mode is off in local development, the HTTP mask keeps `DEMO20007` (or one fallback +project), hides notification rows/counts, and the task PPI service returns a labelled masked state. +When it is on, those API-backed surfaces pass through unchanged. The unit-summary percentage, +burndown curve and push preview use fabricated presentation values. Unit Hub has its own explicit +demo fixtures: with the switch off, it always reads the signed-in account's authorised live API +content, including in a development build. + +The push preview is visual only. It must never inject `SwPush`, request `Notification` permission, +or call `/push_subscriptions`. + +## Unit Hub walkthrough + +Enable **Demo mode** in **Demo controls**, then open **Unit Hub**. The fictional student is enrolled +in SIT111. The sample contains announcements, two HelpHubs and a lecture. SIT102 rows deliberately +exist in the raw fixture to check that unrelated unit content is excluded from the displayed feed. +Click an announcement title or **Read full announcement** to read the complete post. Click a session +title or **View full details** for its description, time, joining and calendar choices. The detail +dialog closes and clears its selected content when the unit, route or demo mode changes. + +The sample dates are generated relative to the local browser date and are not an official timetable. +Staff editing, live API writes and calendar subscriptions remain disabled in the Unit Hub demo. +Google Calendar drafts and downloaded ICS copies are explicitly labelled **DEMO**. They are created +only when the user chooses and do not receive later schedule changes. + +### Optional hosted HelpHub links + +In **Demo controls → Use your own HelpHub links**, paste a Teams joining link for either or both +HelpHubs, then choose **Save demo links**. Open Unit Hub again to see **Join hosted demo**. The lecture +keeps its sample description and calendar actions without a fabricated joining destination. + +Only supported HTTPS joining paths on `teams.microsoft.com` are accepted. The host-provided links +are stored in `sessionStorage` for the current browser tab. They are never sent to OnTrack's API, +seeded into production or committed to the repository. **Clear demo links**, signing out or ending +the browser tab session removes them. Turning demo mode off hides the hosted links but retains them +for another walkthrough in the same tab. Production builds cannot use this store. + +A hosted link opens a real meeting supplied by the presenter. Calendar copies include that link +with fictional dates and a clear demo notice. Opening a Google Calendar draft shares its chosen +details with Google. Keep links out of public screenshots, test fixtures and handover documents. +Without a host-provided link, the sample offers no joining action. + +### Try the Teams meeting draft + +The optional **Try the Teams meeting draft** section opens a real Teams invitation draft with a +**DEMO** title and fictional details. Opening it sends no invitation. Review the example times and +attendees; press **Send** in Teams only if deliberately creating that meeting. Tests inspect the +generated URL without following it or sending anything. + +The same composer is available to assigned teaching staff in the normal session editor and session +detail view. It prefills the subject, description, start/end instants and optional university sign-in +email addresses. Draft-only description edits leave the unsaved OnTrack session intact. Teams +creates the actual meeting join URL after the invitation is sent; copy that URL back into the +OnTrack session and save/publish it. An existing joining or source URL is not automatically copied +into a new invitation. + +Microsoft documents [the scheduling fields and required time offsets](https://learn.microsoft.com/en-us/microsoftteams/platform/concepts/build-and-test/deep-link-workflow). +Repeating schedules must be set in Teams before sending; the link opens one occurrence. Location is +included as description text because the scheduling URL has no location parameter. Details or +attendees that exceed OnTrack's URL bound must be shortened for the draft or added directly in Teams. +The [HTTPS deep-link chooser](https://learn.microsoft.com/en-us/microsoftteams/platform/concepts/build-and-test/deep-links) +offers the app or web client. If a phone cannot open the scheduling draft, use a desktop browser or +copy the displayed details into Teams. This does not require the optional Graph announcement sync +connection, and a separate Teams sign-in may be requested by Microsoft. + +## Study essentials + +Unit Hub, available from the home-page link, shows public university entry points using the explicit +`studyEssentialsProfile` in `src/app/study-essentials/study-essentials.config.ts`. This fork sets +`'deakin'` for development, production and the demo. Other institutions should set it to `''` before +building, or provide their own reviewed profile. The choice is never inferred from a unit code, +email address or product name. See [the Study essentials handover](../study-essentials/README.md). + +The panel links to DeakinSync, CloudDeakin, StudentConnect, timetable guidance, the library and +Student Central. Each destination controls its own sign-in. No account, enrolment, course or meeting +details are appended to these public links. Unit-specific content remains in the scoped Unit Hub. + +## Removal + +1. Remove `DemoToolsModule` from `doubtfire-angular.module.ts` and delete this folder. +2. Remove `/demo-controls` and its imports from `app.routes.ts`. +3. Remove the Demo controls item/store injection from `common/header/header.component.*`. +4. Remove `` from `app.component.html`. +5. Remove `enableDemoTools` from both environment files. +6. Remove the `DemoModeStore.reset()` sign-out hook from `authentication.service.ts`. +7. Remove demo gating/imports from the PPI indicator, progress dashboard, and burndown chart. +8. Either delete the demo-only unit summary and peer-median UI or replace them with authorised live + API adapters before retaining those surfaces. +9. Remove Unit Hub's demo fixture branch and hosted-link store dependency from `UnitHubService`, + keeping its authorised API path. Remove demo notices/actions from Unit Hub, its details dialog + and calendar exports. Remove the demo fixture file and demo-only session marker once unused. + Retain the production Teams meeting composer and the configured Study essentials panel. diff --git a/src/app/demo/demo-controls/demo-controls.component.html b/src/app/demo/demo-controls/demo-controls.component.html new file mode 100644 index 0000000000..583466bfef --- /dev/null +++ b/src/app/demo/demo-controls/demo-controls.component.html @@ -0,0 +1,342 @@ +
+ + + + Demo controls + Local development only + + + +

+ Demo mode combines the feature surfaces used for walkthroughs. It does not replace live CPD + task data or recommendation scores with fabricated values. +

+ + + Demo mode {{ demoMode.enabled ? 'on' : 'off' }} + + +

+ Changing this setting reloads OnTrack so project and notification caches cannot mix quiet + and demonstration data. +

+ +

What the switch does

+
+
Off — quiet baseline
+
+ Keeps one baseline unit (DEMO20007 when available) and hides the demo notification and + peer-comparison surfaces. +
+
On — complete walkthrough
+
+ Shows the complete cross-unit scenario, a privacy-safe lifecycle spread (60% submitted and + 10% complete), and all seven curated local notifications. +
+
+ +

Affected surfaces

+
    + @for (surface of affectedSurfaces; track surface) { +
  • {{ surface }}
  • + } +
+ +
+

Unit Hub: announcements, HelpHubs and classes

+

+ Turn demo mode on above, then open Unit Hub to preview a student enrolled only in SIT111. + The sample includes announcements, two HelpHubs and a lecture. SIT102 content stays + hidden. +

+

+ Demo dates and content are fictional. Subscriptions and editing are disabled. You can + export a calendar copy labelled DEMO. With demo mode off, Unit Hub reads real content for + your account. +

+ + Open Unit Hub +
+ Try the Teams meeting draft +

+ This example opens a real Teams invitation draft with fictional details. OnTrack sends + nothing. In the normal version, assigned teaching staff prepare meetings from the + session editor using their own title, dates and description. +

+ +
+
+ +
+
+ +
+

Peer Progress Indicator visual preview

+

+ Sample only — fabricated anonymous percentages. Switching previews never calls the API + or changes a profile. +

+
+
+ +
+ @for (option of ppiPreviewOptions; track option.kind) { + + } +
+ +
+
+
+ + + Peer Progress Indicator + Anonymous peers at your target grade + +
+ + @if (!ppiPreview.isSuppressed) { + + } +
+ + @if (ppiPreview.isSuppressed) { +
+ + + Progress is hidden to protect privacy + {{ ppiPreview.unavailableMessage }} + +
+ } @else { +
+ + {{ formatPpiPercentage(ppiPreviewPercentage) }}% + of peers have completed this task + + + + + + + +
+ + @if (ppiPreviewAdvanced) { +
+
+ Completed {{ ppiPreview.completedPercentage }}% + +
+ + + Task status breakdown + Independently privacy-rounded; values may not total 100% + + + @if (!ppiPreview.distributionAvailable) { +
+ + + Detailed breakdown protected + The overall percentage is safe to show, but detailed statuses are hidden to + prevent individual progress being inferred. + +
+ } @else if (ppiPreviewUsesStackedDistribution) { +
+ @for (segment of ppiPreviewSegments; track segment.status) { + + } +
+
    + @for (segment of ppiPreviewSegments; track segment.status) { +
  • + + {{ segment.label }} + {{ formatPpiPercentage(segment.percentage) }}% +
  • + } +
+ } @else { +
+

+ These privacy-rounded statuses total + {{ formatPpiPercentage(ppiPreviewDistributionTotal) }}%. Each bar uses its own + 0–100% scale, so values are not stretched to fill 100%. +

+
    + @for (segment of ppiPreviewSegments; track segment.status) { +
  • + + + {{ segment.label }} + + + + + {{ formatPpiPercentage(segment.percentage) }}% +
  • + } +
+
+ } +
+ } + } +
+ +

+ These preview states are local visual fixtures. Production always uses the authenticated, + privacy-filtered API response. +

+
+ +
+
+ +
+

Push notification visual preview

+

Preview only — no browser permission or push subscription is changed.

+
+
+ + + +

+ This preview never calls Notification permission APIs, Angular SwPush, or + /push_subscriptions. +

+ + + + Open My Profile for real notifications + +
+
+
+
diff --git a/src/app/demo/demo-controls/demo-controls.component.scss b/src/app/demo/demo-controls/demo-controls.component.scss new file mode 100644 index 0000000000..964d809d51 --- /dev/null +++ b/src/app/demo/demo-controls/demo-controls.component.scss @@ -0,0 +1,562 @@ +.demo-controls { + margin: 0 auto; + max-width: 56rem; + padding: 2rem 1rem 4rem; +} + +.hub-meeting-links { + display: grid; + gap: 0.75rem; + min-width: 0; + + h3 { + margin: 0; + } + + input { + width: 100%; + min-width: 0; + box-sizing: border-box; + border: 1px solid var(--ot-color-border, #d4d4d8); + border-radius: 6px; + padding: 0.75rem; + background: var(--ot-color-surface, #fff); + color: var(--ot-color-text, #202124); + } +} + +.hub-meeting-links__actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +mat-card-content { + display: grid; + gap: 1rem; + padding-top: 1.5rem; +} + +h2, +p, +ul, +dl, +dd { + margin: 0; +} + +dl { + display: grid; + gap: 0.5rem 1rem; + grid-template-columns: max-content 1fr; +} + +dt { + font-weight: 600; +} + +.push-preview { + border-top: 1px solid #d4d4d8; + display: grid; + gap: 1rem; + margin-top: 0.5rem; + padding-top: 1.5rem; +} + +.ppi-preview { + border-top: 1px solid #d4d4d8; + display: grid; + gap: 1rem; + margin-top: 0.5rem; + min-width: 0; + padding-top: 1.5rem; +} + +.ppi-preview__heading { + align-items: flex-start; + display: flex; + gap: 0.75rem; +} + +.ppi-preview__choices { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.ppi-preview__choice--active { + background: #ecebff; + border-color: #3939ff; + color: #3029a5; +} + +.ppi-preview-card { + background: + radial-gradient(circle at 92% -10%, rgb(57 57 255 / 12%), transparent 34%), + linear-gradient(135deg, #ffffff, #fbfbff); + border: 1px solid #d9d9ef; + border-radius: 12px; + box-sizing: border-box; + box-shadow: 0 3px 12px rgb(29 25 78 / 8%); + container: ppi-preview-card / inline-size; + max-width: 100%; + min-width: 0; + overflow: hidden; +} + +.ppi-preview-card__header { + align-items: center; + display: flex; + gap: 10px; + justify-content: space-between; + padding: 9px 12px 5px; +} + +.ppi-preview-card__identity { + align-items: center; + display: flex; + gap: 8px; + min-width: 0; + + > span:last-child { + min-width: 0; + } + + strong, + small { + display: block; + } + + strong { + font-size: 15px; + letter-spacing: -0.01em; + line-height: 1.25; + } + + small { + color: #52525f; + font-size: 11px; + line-height: 1.3; + margin-top: 1px; + } +} + +.ppi-preview-card__avatar { + align-items: center; + background: linear-gradient(145deg, #eeecff, #dcd9ff); + border: 1px solid #b7b1ff; + border-radius: 50%; + box-shadow: inset 0 0 0 3px rgb(255 255 255 / 58%); + color: #2d23b8; + display: inline-flex; + flex: 0 0 34px; + height: 34px; + justify-content: center; + + mat-icon { + font-size: 20px; + height: 20px; + width: 20px; + } +} + +.ppi-preview-card__toggle { + align-items: center; + appearance: none; + background: transparent; + border: 0; + border-radius: 999px; + color: #33333e; + cursor: pointer; + display: inline-flex; + flex: 0 0 auto; + font: inherit; + font-size: 11px; + font-weight: 600; + gap: 5px; + min-height: 28px; + padding: 3px 4px; + white-space: nowrap; + + &:focus-visible { + outline: 2px solid #3939ff; + outline-offset: 2px; + } +} + +.ppi-preview-card__toggle-track { + background: #737582; + border-radius: 999px; + display: inline-flex; + flex: 0 0 28px; + height: 15px; + padding: 2px; + transition: background 160ms ease; +} + +.ppi-preview-card__toggle-thumb { + background: #ffffff; + border-radius: 50%; + box-shadow: 0 1px 2px rgb(0 0 0 / 28%); + height: 11px; + transform: translateX(0); + transition: transform 160ms ease; + width: 11px; +} + +.ppi-preview-card__toggle[aria-checked='true'] .ppi-preview-card__toggle-track { + background: #3939ff; +} + +.ppi-preview-card__toggle[aria-checked='true'] .ppi-preview-card__toggle-thumb { + transform: translateX(13px); +} + +.ppi-preview-card__summary { + align-items: center; + display: grid; + gap: 12px; + grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.25fr); + padding: 2px 12px 10px 54px; +} + +.ppi-preview-card__value { + align-items: baseline; + display: flex; + gap: 6px; + min-width: 0; + + > span { + font-size: 12px; + font-weight: 600; + line-height: 1.25; + } + + strong { + color: #2d23b8; + font-size: 28px; + letter-spacing: -0.04em; + line-height: 1; + } +} + +.ppi-preview-card__visual { + min-width: 0; +} + +.ppi-preview-card__visual, +.ppi-preview-card__scale { + display: block; +} + +.ppi-preview-card__track { + background: #e9e9ef; + border-radius: 999px; + box-shadow: inset 0 1px 2px rgb(0 0 0 / 10%); + display: block; + height: 9px; + overflow: hidden; + + span { + background: linear-gradient(90deg, #4637ff, #6559ff 62%, #766cfb); + border-radius: inherit; + box-shadow: 0 0 10px rgb(57 57 255 / 34%); + display: block; + height: 100%; + transition: width 260ms ease; + } +} + +.ppi-preview-card__scale { + color: #52525f; + display: flex; + font-size: 10px; + font-weight: 600; + justify-content: space-between; + margin-top: 3px; +} + +.ppi-preview-card__advanced { + background: rgb(246 246 255 / 76%); + border-top: 1px solid #e1e0f2; + display: grid; + gap: 9px; + padding: 10px 12px 12px; +} + +.ppi-preview-card__metrics { + display: grid; + gap: 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + + > span { + align-items: center; + background: #ffffff; + border: 1px solid #dedee8; + border-left: 4px solid; + border-radius: 6px; + display: flex; + font-size: 12px; + font-weight: 600; + gap: 12px; + justify-content: space-between; + min-width: 0; + padding: 5px 8px; + } + + strong { + font-size: 16px; + } + + .is-complete { + border-left-color: #45a049; + } + + .is-submitted { + border-left-color: #0079d8; + } +} + +.ppi-preview-card__advanced-heading, +.ppi-preview-card__advanced-heading small { + display: block; +} + +.ppi-preview-card__advanced-heading small { + color: #626272; + font-size: 11px; + margin-top: 2px; +} + +.ppi-preview-card__advanced-heading strong { + font-size: 14px; +} + +.ppi-preview-bar { + background: #e4e4e7; + border: 2px solid #ffffff; + border-radius: 999px; + box-shadow: 0 1px 4px rgb(0 0 0 / 18%); + display: flex; + height: 14px; + overflow: hidden; + + span { + flex-basis: 0; + + & + span { + border-left: 1px solid rgb(255 255 255 / 58%); + } + } +} + +.ppi-preview-legend { + display: grid; + gap: 4px 6px; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + list-style: none; + padding: 0; + + li { + align-items: center; + background: rgb(255 255 255 / 86%); + border: 1px solid #e2e2eb; + border-radius: 5px; + display: grid; + font-size: 10px; + gap: 5px; + grid-template-columns: 9px minmax(0, 1fr) auto; + min-height: 20px; + padding: 2px 4px; + } + + li > span:first-child { + border: 1px solid rgb(0 0 0 / 12%); + border-radius: 50%; + height: 9px; + width: 9px; + } + + .ppi-preview-legend__name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.ppi-preview-independent { + display: grid; + gap: 7px; + + > p { + background: #ffffff; + border-left: 3px solid #3939ff; + border-radius: 6px; + color: #52525f; + font-size: 11px; + line-height: 1.45; + padding: 5px 7px; + } + + > ul { + display: grid; + gap: 5px; + list-style: none; + padding: 0; + } + + li { + align-items: center; + display: grid; + font-size: 11px; + gap: 7px; + grid-template-columns: minmax(110px, 0.55fr) minmax(80px, 1fr) 34px; + } + + li > span:first-child { + align-items: center; + display: flex; + font-weight: 600; + gap: 7px; + min-width: 0; + } + + li > span:first-child > span { + border: 1px solid rgb(0 0 0 / 12%); + border-radius: 50%; + flex: 0 0 9px; + height: 9px; + width: 9px; + } + + li > strong { + text-align: right; + } +} + +.ppi-preview-independent__track { + background: #e4e4e7; + border-radius: 999px; + box-shadow: inset 0 1px 2px rgb(0 0 0 / 8%); + display: block; + height: 9px; + overflow: hidden; +} + +.ppi-preview-independent__fill { + border-radius: inherit; + display: block; + height: 100%; +} + +.ppi-preview-card__safe-state { + align-items: flex-start; + display: flex; + gap: 12px; + min-height: 42px; + padding: 3px 12px 10px 54px; + + span, + strong { + display: block; + } +} + +.ppi-preview-card__advanced .ppi-preview-card__safe-state { + background: #fffaf0; + border: 1px solid #ead8a7; + border-radius: 8px; + color: #5f4a17; + font-size: 12px; + line-height: 1.45; + min-height: 0; + padding: 8px 9px; +} + +.ppi-preview__safety { + color: #52525b; + font-size: 0.75rem; +} + +@container ppi-preview-card (width < 460px) { + .ppi-preview-card__header { + align-items: flex-start; + } + + .ppi-preview-card__summary, + .ppi-preview-card__safe-state { + padding-left: 12px; + } +} + +@container ppi-preview-card (width < 350px) { + .ppi-preview-card__header { + flex-wrap: wrap; + } + + .ppi-preview-card__toggle { + margin-left: 42px; + } + + .ppi-preview-card__value strong { + font-size: 25px; + } + + .ppi-preview-card__summary { + grid-template-columns: 1fr; + } + + .ppi-preview-legend { + grid-template-columns: 1fr; + } + + .ppi-preview-independent li { + align-items: start; + grid-template-columns: 1fr auto; + } + + .ppi-preview-independent__track { + grid-column: 1 / -1; + grid-row: 2; + } +} + +@media (prefers-reduced-motion: reduce) { + .ppi-preview-card__track span, + .ppi-preview-card__toggle-track, + .ppi-preview-card__toggle-thumb { + transition: none; + } +} + +.push-preview__heading { + align-items: flex-start; + display: flex; + gap: 0.75rem; +} + +.push-preview__notification { + background: #ffffff; + border: 1px solid #d4d4d8; + border-radius: 0.75rem; + box-shadow: 0 0.5rem 1.5rem rgb(0 0 0 / 12%); + display: grid; + gap: 0.25rem; + max-width: 30rem; + padding: 1rem; +} + +.push-preview__notification small, +.push-preview__safety { + color: #52525b; +} + +@media (width < 40rem) { + dl { + grid-template-columns: 1fr; + } + + dd:not(:last-child) { + margin-bottom: 0.5rem; + } +} diff --git a/src/app/demo/demo-controls/demo-controls.component.spec.ts b/src/app/demo/demo-controls/demo-controls.component.spec.ts new file mode 100644 index 0000000000..35cc140bf6 --- /dev/null +++ b/src/app/demo/demo-controls/demo-controls.component.spec.ts @@ -0,0 +1,184 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {provideHttpClient} from '@angular/common/http'; +import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {MatSlideToggleChange} from '@angular/material/slide-toggle'; +import {NoopAnimationsModule} from '@angular/platform-browser/animations'; +import {provideRouter} from '@angular/router'; +import {SwPush} from '@angular/service-worker'; +import {DemoModeStore} from '../demo-mode.store'; +import {DemoToolsModule} from '../demo-tools.module'; +import {DEMO_RELOAD, DemoControlsComponent} from './demo-controls.component'; + +describe('DemoControlsComponent', () => { + let fixture: ComponentFixture; + let demoMode: { + available: boolean; + enabled: boolean; + setEnabled: ReturnType; + }; + let reload: ReturnType; + let requestPermission: ReturnType; + let requestSubscription: ReturnType; + let httpTesting: HttpTestingController; + let originalNotification: typeof Notification | undefined; + + beforeEach(async () => { + demoMode = {available: true, enabled: false, setEnabled: vi.fn()}; + reload = vi.fn(); + requestPermission = vi.fn(); + requestSubscription = vi.fn(); + originalNotification = globalThis.Notification; + Object.defineProperty(globalThis, 'Notification', { + configurable: true, + value: {permission: 'default', requestPermission}, + }); + + await TestBed.configureTestingModule({ + imports: [DemoToolsModule, NoopAnimationsModule], + providers: [ + {provide: DemoModeStore, useValue: demoMode}, + {provide: DEMO_RELOAD, useValue: reload}, + {provide: SwPush, useValue: {requestSubscription}}, + provideRouter([]), + provideHttpClient(), + provideHttpClientTesting(), + ], + }).compileComponents(); + + fixture = TestBed.createComponent(DemoControlsComponent); + fixture.detectChanges(); + httpTesting = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpTesting.verify(); + Object.defineProperty(globalThis, 'Notification', { + configurable: true, + value: originalNotification, + }); + }); + + it('shows an accessible control and an exact description of affected surfaces', () => { + const text = fixture.nativeElement.textContent; + const toggle = fixture.nativeElement.querySelector('mat-slide-toggle'); + + expect(toggle.getAttribute('aria-describedby')).toBe('demo-mode-description'); + expect(text).toContain('Demo mode off'); + expect(text).toContain('Off — quiet baseline'); + expect(text).toContain('one baseline unit (DEMO20007 when available)'); + expect(text).toContain('On — complete walkthrough'); + expect(text).toContain('privacy-safe lifecycle spread (60% submitted and 10% complete)'); + expect(text).toContain('all seven curated local notifications'); + expect(text).toContain('live local project cards'); + expect(text).toContain('live notification bell'); + expect(text).toContain('Live task-level peer comparison'); + expect(text).toContain('unit summary'); + expect(text).toContain('Peer Progress Indicator visual preview'); + expect(text).toContain('Full status data'); + expect(text).toContain('Insufficient cohort'); + expect(text).toContain('Advanced details protected'); + expect(text).toContain('Rounded total 90%'); + expect(text).toContain('Rounded total 110%'); + expect(fixture.nativeElement.querySelectorAll('.ppi-preview__choices button').length).toBe(5); + }); + + it('keeps the named demo section inside the app-owned main landmark', () => { + const element = fixture.nativeElement as HTMLElement; + expect(element.querySelector('main, [role="main"]')).toBeNull(); + const section = element.querySelector('section.demo-controls'); + expect(section.getAttribute('aria-labelledby')).toBe('demo-controls-title'); + expect(section.querySelector('#demo-controls-title').textContent.trim()).toBe('Demo controls'); + }); + + it('persists the switch and reloads to avoid mixed entity caches', () => { + fixture.componentInstance.setDemoMode({checked: true} as MatSlideToggleChange); + + expect(demoMode.setEnabled).toHaveBeenCalledWith(true); + expect(reload).toHaveBeenCalledOnce(); + }); + + it('renders a visual-only push preview without browser, SwPush, or API calls', () => { + const text = fixture.nativeElement.textContent; + + expect(text).toContain('Preview only'); + expect(text).toContain('Feedback is ready'); + expect(text).toContain('Open My Profile for real notifications'); + expect( + fixture.nativeElement.querySelector('a[href="/edit_profile"]').getAttribute('href'), + ).toBe('/edit_profile'); + expect(requestPermission).not.toHaveBeenCalled(); + expect(requestSubscription).not.toHaveBeenCalled(); + httpTesting.expectNone((request) => request.url.includes('/push_subscriptions')); + }); + + it('switches between complete and privacy-safe peer progress preview states locally', () => { + fixture.componentInstance.setPpiPreviewAdvanced(true); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain('Task status breakdown'); + expect(fixture.nativeElement.textContent).toContain('60%'); + expect(fixture.nativeElement.textContent).toContain('10%'); + + fixture.componentInstance.setPpiPreview('details-protected'); + fixture.componentInstance.setPpiPreviewAdvanced(true); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain('Detailed breakdown protected'); + expect(fixture.nativeElement.textContent).not.toContain('Redo'); + + fixture.componentInstance.setPpiPreview('insufficient'); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain('Progress is hidden to protect privacy'); + expect(fixture.nativeElement.textContent).toContain('Not enough students'); + + httpTesting.expectNone((request) => request.url.includes('/peer_progress')); + }); + + it('keeps the sample compact by default and exposes details through a small accessible switch', () => { + const preview = fixture.nativeElement.querySelector('.ppi-preview-card') as HTMLElement; + const toggle = preview.querySelector('button[role="switch"]') as HTMLButtonElement; + const progress = preview.querySelector('[role="progressbar"]') as HTMLElement; + + expect(toggle.getAttribute('aria-label')).toBe('Advanced peer status breakdown'); + expect(toggle.getAttribute('aria-controls')).toBe('ppi-preview-advanced-panel'); + expect(toggle.getAttribute('aria-checked')).toBe('false'); + expect(toggle.getAttribute('aria-expanded')).toBe('false'); + expect(preview.querySelector('.ppi-preview-card__advanced')).toBeNull(); + expect(progress.getAttribute('aria-label')).toBe( + '10% of peers at your target grade have completed this task', + ); + + toggle.click(); + fixture.detectChanges(); + + expect(toggle.getAttribute('aria-checked')).toBe('true'); + expect(toggle.getAttribute('aria-expanded')).toBe('true'); + expect(preview.querySelector('#ppi-preview-advanced-panel')).toBeTruthy(); + expect(preview.textContent).toContain('Redo'); + expect(preview.textContent).toContain('Resubmit'); + }); + + it.each([ + {kind: 'rounded-90' as const, total: 90}, + {kind: 'rounded-110' as const, total: 110}, + ])('previews a $total% rounded vector without visually renormalising it', ({kind, total}) => { + fixture.componentInstance.setPpiPreview(kind); + fixture.componentInstance.setPpiPreviewAdvanced(true); + fixture.detectChanges(); + + const independent = fixture.nativeElement.querySelector('.ppi-preview-independent'); + const workingFill = fixture.nativeElement.querySelector( + '[data-status="working_on_it"] .ppi-preview-independent__fill', + ) as HTMLElement; + const workingTrack = fixture.nativeElement.querySelector( + '[data-status="working_on_it"] [role="progressbar"]', + ) as HTMLElement; + + expect(fixture.componentInstance.ppiPreviewDistributionTotal).toBe(total); + expect(fixture.nativeElement.querySelector('.ppi-preview-bar')).toBeNull(); + expect(independent.textContent).toContain(`total ${total}%`); + expect(independent.textContent).toContain('not stretched to fill 100%'); + expect(workingFill.style.width).toBe('20%'); + expect(workingTrack.getAttribute('aria-valuenow')).toBe('20'); + expect(workingTrack.getAttribute('aria-describedby')).toBe('ppi-preview-independent-note'); + }); +}); diff --git a/src/app/demo/demo-controls/demo-controls.component.ts b/src/app/demo/demo-controls/demo-controls.component.ts new file mode 100644 index 0000000000..18be520b63 --- /dev/null +++ b/src/app/demo/demo-controls/demo-controls.component.ts @@ -0,0 +1,174 @@ +import {ChangeDetectionStrategy, Component, Inject, InjectionToken} from '@angular/core'; +import {FormControl, FormGroup} from '@angular/forms'; +import {MatSlideToggleChange} from '@angular/material/slide-toggle'; +import {PeerProgressIndicator} from 'src/app/api/models/peer-progress-indicator'; +import {TaskStatus, TaskStatusEnum} from 'src/app/api/models/task-status'; +import {TeamsMeetingDraft} from '../../unit-hub/teams-meeting-draft'; +import {unitHubDemo} from '../../unit-hub/unit-hub-demo.fixtures'; +import {DemoMeetingLinksStore} from '../demo-meeting-links.store'; +import {DemoModeStore} from '../demo-mode.store'; +import { + DETAIL_PROTECTED_STATE, + NORMAL_STATE, + ROUNDED_90_STATE, + ROUNDED_110_STATE, + SUPPRESSED_STATE, +} from '../fixtures/peer-progress-demo.fixtures'; +import {DEMO_PUSH_PREVIEW} from '../fixtures/push-preview.fixture'; + +type PpiPreviewKind = 'full' | 'rounded-90' | 'rounded-110' | 'insufficient' | 'details-protected'; + +interface PpiPreviewSegment { + status: TaskStatusEnum; + label: string; + color: string; + percentage: number; +} + +export const DEMO_RELOAD: InjectionToken<() => void> = new InjectionToken('DEMO_RELOAD', { + providedIn: 'root', + factory: () => () => globalThis.location?.reload(), +}); + +@Component({ + selector: 'f-demo-controls', + templateUrl: './demo-controls.component.html', + styleUrls: ['./demo-controls.component.scss'], + changeDetection: ChangeDetectionStrategy.Eager, + standalone: false, +}) +export class DemoControlsComponent { + readonly demoMeetingDraft: TeamsMeetingDraft = { + ...unitHubDemo().sessions[0], + title: 'DEMO · HelpHub meeting draft', + description: + 'Fictional OnTrack demonstration. Review the example date and time before sending any invitation.', + }; + readonly meetingLinksForm = new FormGroup({ + helpHub: new FormControl('', {nonNullable: true}), + extraHelpHub: new FormControl('', {nonNullable: true}), + }); + meetingLinksError = ''; + meetingLinksStatus = ''; + readonly pushPreview = DEMO_PUSH_PREVIEW; + readonly ppiPreviewOptions: {kind: PpiPreviewKind; label: string}[] = [ + {kind: 'full', label: 'Full status data'}, + {kind: 'rounded-90', label: 'Rounded total 90%'}, + {kind: 'rounded-110', label: 'Rounded total 110%'}, + {kind: 'insufficient', label: 'Insufficient cohort'}, + {kind: 'details-protected', label: 'Advanced details protected'}, + ]; + + ppiPreviewKind: PpiPreviewKind = 'full'; + ppiPreviewAdvanced = false; + private readonly ppiStatusDisplayIndex = new Map( + TaskStatus.PEER_PROGRESS_DISPLAY_ORDER.map((status, index) => [status, index]), + ); + + readonly affectedSurfaces = [ + 'Unit Hub: fictional SIT111 announcements and sessions, with SIT102 excluded and API writes disabled', + 'All live local project cards, tasks, deadline warnings, filters, and recommendation scores', + 'The live notification bell, unread count, and notification history', + 'Live task-level peer comparison data returned by the local API', + 'A privacy-safe lifecycle spread (60% submitted and 10% complete), unit summary, and anonymous burndown sample', + ]; + + constructor( + readonly demoMode: DemoModeStore, + @Inject(DEMO_RELOAD) private reload: () => void, + private meetingLinks: DemoMeetingLinksStore, + ) { + this.meetingLinksForm.setValue(this.meetingLinks.links); + } + + saveMeetingLinks(): void { + this.meetingLinksError = ''; + this.meetingLinksStatus = ''; + try { + this.meetingLinks.save(this.meetingLinksForm.getRawValue()); + this.meetingLinksStatus = + 'Saved for this browser tab. Open Unit Hub to use the links in demo mode.'; + } catch (error) { + this.meetingLinksError = + error instanceof Error ? error.message : 'The links could not be saved.'; + } + } + + clearMeetingLinks(): void { + this.meetingLinks.clear(); + this.meetingLinksForm.reset(); + this.meetingLinksError = ''; + this.meetingLinksStatus = 'Hosted links removed. Sample meetings will be disabled.'; + } + + setDemoMode(change: MatSlideToggleChange): void { + if (change.checked === this.demoMode.enabled) { + return; + } + + this.demoMode.setEnabled(change.checked); + this.reload(); + } + + setPpiPreview(kind: PpiPreviewKind): void { + this.ppiPreviewKind = kind; + this.ppiPreviewAdvanced = false; + } + + setPpiPreviewAdvanced(advanced: boolean): void { + this.ppiPreviewAdvanced = advanced; + } + + get ppiPreview(): PeerProgressIndicator { + switch (this.ppiPreviewKind) { + case 'insufficient': + return SUPPRESSED_STATE; + case 'details-protected': + return DETAIL_PROTECTED_STATE; + case 'rounded-90': + return ROUNDED_90_STATE; + case 'rounded-110': + return ROUNDED_110_STATE; + default: + return NORMAL_STATE; + } + } + + get ppiPreviewPercentage(): number { + return this.ppiPreview.completedPercentage ?? this.ppiPreview.submittedPercentage ?? 0; + } + + get ppiPreviewWidth(): string { + return `${Math.min(100, Math.max(0, this.ppiPreviewPercentage))}%`; + } + + get ppiPreviewSegments(): PpiPreviewSegment[] { + return this.ppiPreview.statusDistribution + .filter((entry) => entry.percentage > 0) + .sort( + (left, right) => + (this.ppiStatusDisplayIndex.get(left.status) ?? Number.MAX_SAFE_INTEGER) - + (this.ppiStatusDisplayIndex.get(right.status) ?? Number.MAX_SAFE_INTEGER), + ) + .map((entry) => ({ + ...entry, + label: + entry.status === 'ready_for_feedback' + ? 'Ready for Feedback' + : (TaskStatus.STATUS_LABELS.get(entry.status) ?? entry.status), + color: TaskStatus.STATUS_COLORS.get(entry.status) ?? '#64748b', + })); + } + + get ppiPreviewDistributionTotal(): number { + return this.ppiPreviewSegments.reduce((total, segment) => total + segment.percentage, 0); + } + + get ppiPreviewUsesStackedDistribution(): boolean { + return this.ppiPreviewDistributionTotal === 100; + } + + formatPpiPercentage(value: number): string { + return Number.isInteger(value) ? `${value}` : value.toFixed(1).replace(/\.0$/, ''); + } +} diff --git a/src/app/demo/demo-data-mask.interceptor.spec.ts b/src/app/demo/demo-data-mask.interceptor.spec.ts new file mode 100644 index 0000000000..7531b42331 --- /dev/null +++ b/src/app/demo/demo-data-mask.interceptor.spec.ts @@ -0,0 +1,91 @@ +import {afterEach, beforeEach, describe, expect, it} from 'vitest'; +import { + HTTP_INTERCEPTORS, + HttpClient, + provideHttpClient, + withInterceptorsFromDi, +} 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 { + DemoDataMaskInterceptor, + filterProjectCollectionForQuietMode, +} from './demo-data-mask.interceptor'; +import {DemoModeStore} from './demo-mode.store'; + +describe('DemoDataMaskInterceptor', () => { + let http: HttpClient; + let httpTesting: HttpTestingController; + let demoMode: {shouldMaskApiData: boolean}; + + beforeEach(() => { + demoMode = {shouldMaskApiData: true}; + + TestBed.configureTestingModule({ + providers: [ + {provide: DemoModeStore, useValue: demoMode}, + { + provide: HTTP_INTERCEPTORS, + useClass: DemoDataMaskInterceptor, + multi: true, + }, + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + ], + }); + + http = TestBed.inject(HttpClient); + httpTesting = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpTesting.verify(); + }); + + it('retains only DEMO20007 from the live projects response while demo mode is off', () => { + let result: unknown; + http.get(`${API_URL}/projects`).subscribe((value) => (result = value)); + + httpTesting.expectOne(`${API_URL}/projects`).flush([ + {id: 1, unit: {code: 'COS10001'}}, + {id: 2, unit: {code: 'DEMO20007'}}, + {id: 3, unit: {code: 'COS30003'}}, + ]); + + expect(result).toEqual([{id: 2, unit: {code: 'DEMO20007'}}]); + }); + + it('falls back to one project for a legacy local dataset', () => { + expect( + filterProjectCollectionForQuietMode([ + {id: 1, unit: {code: 'COS10001'}}, + {id: 2, unit: {code: 'COS20002'}}, + ]), + ).toEqual([{id: 1, unit: {code: 'COS10001'}}]); + }); + + it('masks notification rows and unread count without calling the backend', () => { + let notifications: unknown; + let unread: unknown; + + http.get(`${API_URL}/notifications/`).subscribe((value) => (notifications = value)); + http.get(`${API_URL}/notifications/unread_count`).subscribe((value) => (unread = value)); + + httpTesting.expectNone(`${API_URL}/notifications/`); + httpTesting.expectNone(`${API_URL}/notifications/unread_count`); + expect(notifications).toEqual([]); + expect(unread).toEqual({count: 0}); + }); + + it('passes API-backed data through unchanged when demo mode is on or unavailable', () => { + demoMode.shouldMaskApiData = false; + let result: unknown; + http.get(`${API_URL}/notifications/`).subscribe((value) => (result = value)); + + const live = [{id: 8, message: 'Live notification'}]; + httpTesting.expectOne(`${API_URL}/notifications/`).flush(live); + + expect(result).toEqual(live); + }); +}); diff --git a/src/app/demo/demo-data-mask.interceptor.ts b/src/app/demo/demo-data-mask.interceptor.ts new file mode 100644 index 0000000000..36423e743a --- /dev/null +++ b/src/app/demo/demo-data-mask.interceptor.ts @@ -0,0 +1,78 @@ +import { + HttpEvent, + HttpHandler, + HttpInterceptor, + HttpRequest, + HttpResponse, +} from '@angular/common/http'; +import {Injectable} from '@angular/core'; +import {Observable, map, of} from 'rxjs'; +import {DemoModeStore} from './demo-mode.store'; + +const DEMO_UNIT_CODE = 'DEMO20007'; + +type JsonRecord = Record; + +export function filterProjectCollectionForQuietMode(body: unknown): unknown { + if (Array.isArray(body)) { + return filterProjects(body); + } + + if (isRecord(body) && Array.isArray(body.data)) { + return {...body, data: filterProjects(body.data)}; + } + + return body; +} + +function filterProjects(projects: unknown[]): unknown[] { + const preferred = projects.filter((project) => projectUnitCode(project) === DEMO_UNIT_CODE); + return preferred.length > 0 ? preferred : projects.slice(0, 1); +} + +function projectUnitCode(project: unknown): string | null { + if (!isRecord(project)) { + return null; + } + + const unit = isRecord(project.unit) ? project.unit : null; + const value = unit?.code ?? project.unit_code ?? project.unitCode; + return typeof value === 'string' ? value.toUpperCase() : null; +} + +function isRecord(value: unknown): value is JsonRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +@Injectable() +export class DemoDataMaskInterceptor implements HttpInterceptor { + constructor(private demoMode: DemoModeStore) {} + + intercept(request: HttpRequest, next: HttpHandler): Observable> { + if (!this.demoMode.shouldMaskApiData) { + return next.handle(request); + } + + if (request.method === 'GET' && /\/api\/notifications\/?$/.test(request.url)) { + return of(new HttpResponse({body: [], status: 200, url: request.urlWithParams})); + } + + if (request.method === 'GET' && /\/api\/notifications\/unread_count\/?$/.test(request.url)) { + return of(new HttpResponse({body: {count: 0}, status: 200, url: request.urlWithParams})); + } + + if (request.method === 'GET' && /\/api\/projects\/?$/.test(request.url)) { + return next + .handle(request) + .pipe( + map((event) => + event instanceof HttpResponse + ? event.clone({body: filterProjectCollectionForQuietMode(event.body)}) + : event, + ), + ); + } + + return next.handle(request); + } +} diff --git a/src/app/demo/demo-meeting-links.store.spec.ts b/src/app/demo/demo-meeting-links.store.spec.ts new file mode 100644 index 0000000000..fefee5c114 --- /dev/null +++ b/src/app/demo/demo-meeting-links.store.spec.ts @@ -0,0 +1,86 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {DemoMeetingLinksStore, safeDemoMeetingUrl} from './demo-meeting-links.store'; +import {DEMO_MEETING_LINKS_KEY, DemoModeStore} from './demo-mode.store'; + +const hosted = 'https://teams.microsoft.com/meet/123456789?p=demo-example'; + +describe('Host-provided demo meeting links', () => { + beforeEach(() => sessionStorage.clear()); + afterEach(() => { + vi.unstubAllGlobals(); + sessionStorage.clear(); + vi.restoreAllMocks(); + }); + + it('accepts both Microsoft joining formats and preserves encoded meeting context', () => { + expect(safeDemoMeetingUrl(hosted)).toBe(hosted); + const legacy = + 'https://teams.microsoft.com/l/meetup-join/19%3Aexample%40thread.tacv2/12345?context=%7B%22Tid%22%3A%22example%22%7D'; + expect(safeDemoMeetingUrl(legacy)).toBe(legacy); + }); + + it.each([ + 'javascript:alert(1)', + 'http://teams.microsoft.com/meet/123', + 'https://teams.microsoft.com.evil.test/meet/123', + 'https://evil.test/meet/123', + 'https://teams.microsoft.com@evil.test/meet/123', + 'https://user:password@teams.microsoft.com/meet/123', + 'https://teams.microsoft.com:8443/meet/123', + 'https://teams.microsoft.com/l/meeting/new?subject=example', + 'https://teams.microsoft.com/meet/123#redirect', + 'https://teams.microsoft.com/meet/123\n', + ])('rejects unsafe or non-joining URL %s', (url) => { + expect(safeDemoMeetingUrl(url)).toBeNull(); + }); + + it('persists only in the current tab and revalidates stored data', () => { + const store = new DemoMeetingLinksStore(true); + store.save({helpHub: hosted, extraHelpHub: ''}); + expect(new DemoMeetingLinksStore(true).links.helpHub).toBe(hosted); + sessionStorage.setItem( + DEMO_MEETING_LINKS_KEY, + JSON.stringify({helpHub: 'https://evil.test/meet/123', extraHelpHub: 42}), + ); + expect(store.links).toEqual({helpHub: '', extraHelpHub: ''}); + sessionStorage.setItem(DEMO_MEETING_LINKS_KEY, 'null'); + expect(store.links).toEqual({helpHub: '', extraHelpHub: ''}); + }); + + it('rejects invalid edits without replacing the existing configuration', () => { + const store = new DemoMeetingLinksStore(true); + store.save({helpHub: hosted, extraHelpHub: ''}); + expect(() => store.save({helpHub: hosted, extraHelpHub: 'https://evil.test'})).toThrow(); + expect(store.links.helpHub).toBe(hosted); + }); + + it('disables and clears links without demo tools, including forged saved values', () => { + sessionStorage.setItem(DEMO_MEETING_LINKS_KEY, JSON.stringify({helpHub: hosted})); + const store = new DemoMeetingLinksStore(false); + expect(store.links.helpHub).toBe(''); + expect(sessionStorage.getItem(DEMO_MEETING_LINKS_KEY)).toBeNull(); + expect(() => store.save({helpHub: hosted, extraHelpHub: ''})).toThrow(); + }); + + it('retains links across mode toggles but clears them when authentication resets demo state', () => { + const store = new DemoMeetingLinksStore(true); + const mode = new DemoModeStore(true); + store.save({helpHub: hosted, extraHelpHub: ''}); + mode.setEnabled(true); + mode.setEnabled(false); + expect(store.links.helpHub).toBe(hosted); + mode.reset(); + expect(store.links.helpHub).toBe(''); + }); + + it('reports storage failure instead of claiming the links were saved', () => { + vi.stubGlobal('sessionStorage', { + setItem: () => { + throw new Error('denied'); + }, + }); + expect(() => new DemoMeetingLinksStore(true).save({helpHub: hosted, extraHelpHub: ''})).toThrow( + 'could not save', + ); + }); +}); diff --git a/src/app/demo/demo-meeting-links.store.ts b/src/app/demo/demo-meeting-links.store.ts new file mode 100644 index 0000000000..36969c367b --- /dev/null +++ b/src/app/demo/demo-meeting-links.store.ts @@ -0,0 +1,90 @@ +import {Inject, Injectable} from '@angular/core'; +import {DEMO_MEETING_LINKS_KEY, DEMO_TOOLS_AVAILABLE} from './demo-mode.store'; + +export interface DemoMeetingLinks { + helpHub: string; + extraHelpHub: string; +} + +/** Accept joining links only: no redirects, credentials, ports or arbitrary websites. */ +export function safeDemoMeetingUrl(value: string): string | null { + if (!value || value.length > 4096 || /[\p{Cc}\s]/u.test(value)) { + return null; + } + try { + const url = new URL(value); + return url.protocol === 'https:' && + url.hostname === 'teams.microsoft.com' && + !url.port && + !url.username && + !url.password && + !url.hash && + (/^\/meet\/\d+\/?$/.test(url.pathname) || + /^\/l\/meetup-join\/[^/]+\/\d+\/?$/.test(url.pathname)) + ? url.href + : null; + } catch { + return null; + } +} + +/** Host-provided access links belong to this browser tab, never API data or public fixtures. */ +@Injectable({providedIn: 'root'}) +export class DemoMeetingLinksStore { + constructor(@Inject(DEMO_TOOLS_AVAILABLE) private available: boolean) { + if (!available) { + this.clear(); + } + } + + get links(): DemoMeetingLinks { + const empty = {helpHub: '', extraHelpHub: ''}; + if (!this.available) { + return empty; + } + try { + const stored = JSON.parse(sessionStorage.getItem(DEMO_MEETING_LINKS_KEY) || '{}'); + return { + helpHub: + typeof stored?.helpHub === 'string' ? safeDemoMeetingUrl(stored.helpHub) || '' : '', + extraHelpHub: + typeof stored?.extraHelpHub === 'string' + ? safeDemoMeetingUrl(stored.extraHelpHub) || '' + : '', + }; + } catch { + return empty; + } + } + + save(links: DemoMeetingLinks): void { + if (!this.available) { + throw new Error('Hosted demo links are available only in development.'); + } + const checked: DemoMeetingLinks = {helpHub: '', extraHelpHub: ''}; + for (const key of ['helpHub', 'extraHelpHub'] as const) { + const value = links[key].trim(); + if (value && !safeDemoMeetingUrl(value)) { + throw new Error( + 'Use a complete HTTPS Teams joining link for each HelpHub, or leave it blank.', + ); + } + checked[key] = value ? safeDemoMeetingUrl(value)! : ''; + } + try { + sessionStorage.setItem(DEMO_MEETING_LINKS_KEY, JSON.stringify(checked)); + } catch { + throw new Error( + 'This browser could not save the links. Allow session storage and try again.', + ); + } + } + + clear(): void { + try { + globalThis.sessionStorage?.removeItem(DEMO_MEETING_LINKS_KEY); + } catch { + // Production cannot read hosted links even when storage is unavailable. + } + } +} diff --git a/src/app/demo/demo-mode-banner/demo-mode-banner.component.html b/src/app/demo/demo-mode-banner/demo-mode-banner.component.html new file mode 100644 index 0000000000..d81e59f1d9 --- /dev/null +++ b/src/app/demo/demo-mode-banner/demo-mode-banner.component.html @@ -0,0 +1,8 @@ +@if (demoMode.enabled$ | async) { + +} diff --git a/src/app/demo/demo-mode-banner/demo-mode-banner.component.scss b/src/app/demo/demo-mode-banner/demo-mode-banner.component.scss new file mode 100644 index 0000000000..7247eb0865 --- /dev/null +++ b/src/app/demo/demo-mode-banner/demo-mode-banner.component.scss @@ -0,0 +1,16 @@ +.demo-mode-banner { + align-items: center; + background: #fff4cc; + border-bottom: 1px solid #9a6700; + color: #5f3b00; + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + justify-content: center; + padding: 0.5rem 1rem; +} + +.demo-mode-banner a, +.demo-mode-banner button { + color: inherit; +} diff --git a/src/app/demo/demo-mode-banner/demo-mode-banner.component.spec.ts b/src/app/demo/demo-mode-banner/demo-mode-banner.component.spec.ts new file mode 100644 index 0000000000..d0871ff50d --- /dev/null +++ b/src/app/demo/demo-mode-banner/demo-mode-banner.component.spec.ts @@ -0,0 +1,46 @@ +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {NoopAnimationsModule} from '@angular/platform-browser/animations'; +import {provideRouter} from '@angular/router'; +import {BehaviorSubject} from 'rxjs'; +import {DEMO_RELOAD} from '../demo-controls/demo-controls.component'; +import {DemoModeStore} from '../demo-mode.store'; +import {DemoToolsModule} from '../demo-tools.module'; +import {DemoModeBannerComponent} from './demo-mode-banner.component'; + +describe('DemoModeBannerComponent', () => { + let fixture: ComponentFixture; + let enabled: BehaviorSubject; + let reset: ReturnType; + let reload: ReturnType; + + beforeEach(async () => { + enabled = new BehaviorSubject(true); + reset = vi.fn(() => enabled.next(false)); + reload = vi.fn(); + + await TestBed.configureTestingModule({ + imports: [DemoToolsModule, NoopAnimationsModule], + providers: [ + {provide: DemoModeStore, useValue: {enabled$: enabled.asObservable(), reset}}, + {provide: DEMO_RELOAD, useValue: reload}, + provideRouter([]), + ], + }).compileComponents(); + + fixture = TestBed.createComponent(DemoModeBannerComponent); + fixture.detectChanges(); + }); + + it('stays visible across routes while demo mode is active', () => { + expect(fixture.nativeElement.textContent).toContain('Demo mode:'); + expect(fixture.nativeElement.textContent).toContain('Exit demo'); + }); + + it('exits demo mode and reloads the application', () => { + fixture.componentInstance.exitDemo(); + + expect(reset).toHaveBeenCalledOnce(); + expect(reload).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/app/demo/demo-mode-banner/demo-mode-banner.component.ts b/src/app/demo/demo-mode-banner/demo-mode-banner.component.ts new file mode 100644 index 0000000000..0d71af378b --- /dev/null +++ b/src/app/demo/demo-mode-banner/demo-mode-banner.component.ts @@ -0,0 +1,22 @@ +import {ChangeDetectionStrategy, Component, Inject} from '@angular/core'; +import {DEMO_RELOAD} from '../demo-controls/demo-controls.component'; +import {DemoModeStore} from '../demo-mode.store'; + +@Component({ + selector: 'f-demo-mode-banner', + templateUrl: './demo-mode-banner.component.html', + styleUrls: ['./demo-mode-banner.component.scss'], + changeDetection: ChangeDetectionStrategy.Eager, + standalone: false, +}) +export class DemoModeBannerComponent { + constructor( + readonly demoMode: DemoModeStore, + @Inject(DEMO_RELOAD) private reload: () => void, + ) {} + + exitDemo(): void { + this.demoMode.reset(); + this.reload(); + } +} diff --git a/src/app/demo/demo-mode.store.spec.ts b/src/app/demo/demo-mode.store.spec.ts new file mode 100644 index 0000000000..a547ee6bf9 --- /dev/null +++ b/src/app/demo/demo-mode.store.spec.ts @@ -0,0 +1,46 @@ +import {beforeEach, describe, expect, it} from 'vitest'; +import {DEMO_MODE_STORAGE_KEY, DemoModeStore} from './demo-mode.store'; + +describe('DemoModeStore', () => { + beforeEach(() => { + sessionStorage.clear(); + }); + + it('is available but defaults off in a development demo build', () => { + const store = new DemoModeStore(true); + + expect(store.available).toBe(true); + expect(store.enabled).toBe(false); + expect(store.shouldMaskApiData).toBe(true); + }); + + it('persists the enabled state in session storage', () => { + const firstStore = new DemoModeStore(true); + firstStore.setEnabled(true); + + expect(sessionStorage.getItem(DEMO_MODE_STORAGE_KEY)).toBe('true'); + expect(new DemoModeStore(true).enabled).toBe(true); + }); + + it('reset fails closed and removes the stored state', () => { + const store = new DemoModeStore(true); + store.setEnabled(true); + + store.reset(); + + expect(store.enabled).toBe(false); + expect(sessionStorage.getItem(DEMO_MODE_STORAGE_KEY)).toBeNull(); + }); + + it('cannot enable in production and leaves genuine API data unmasked', () => { + sessionStorage.setItem(DEMO_MODE_STORAGE_KEY, 'true'); + const store = new DemoModeStore(false); + + store.setEnabled(true); + + expect(store.available).toBe(false); + expect(store.enabled).toBe(false); + expect(store.shouldMaskApiData).toBe(false); + expect(sessionStorage.getItem(DEMO_MODE_STORAGE_KEY)).toBeNull(); + }); +}); diff --git a/src/app/demo/demo-mode.store.ts b/src/app/demo/demo-mode.store.ts new file mode 100644 index 0000000000..0e9d231552 --- /dev/null +++ b/src/app/demo/demo-mode.store.ts @@ -0,0 +1,93 @@ +import {environment} from 'src/environments/environment'; +import {Inject, Injectable, InjectionToken} from '@angular/core'; +import {BehaviorSubject, Observable, distinctUntilChanged} from 'rxjs'; + +export const DEMO_MODE_STORAGE_KEY = 'ontrack_demo_mode_enabled'; +export const DEMO_MEETING_LINKS_KEY = 'ontrack_demo_meeting_links'; + +export const DEMO_TOOLS_AVAILABLE: InjectionToken = new InjectionToken( + 'DEMO_TOOLS_AVAILABLE', + { + providedIn: 'root', + factory: () => environment.enableDemoTools === true && environment.production === false, + }, +); + +@Injectable({providedIn: 'root'}) +export class DemoModeStore { + private readonly enabledSubject: BehaviorSubject; + + readonly enabled$: Observable; + + constructor(@Inject(DEMO_TOOLS_AVAILABLE) readonly available: boolean) { + const enabled = available && this.readStoredValue(); + + if (!available) { + this.removeStoredValue(); + } + + this.enabledSubject = new BehaviorSubject(enabled); + this.enabled$ = this.enabledSubject.asObservable().pipe(distinctUntilChanged()); + } + + get enabled(): boolean { + return this.available && this.enabledSubject.value; + } + + /** + * Local development starts in the intentionally quiet state. Production and + * builds without demo tools must always pass API data through unchanged. + */ + get shouldMaskApiData(): boolean { + return this.available && !this.enabled; + } + + setEnabled(enabled: boolean): void { + if (!this.available) { + this.reset(); + return; + } + + if (enabled) { + this.writeStoredValue(); + } else { + this.removeStoredValue(); + } + + this.enabledSubject.next(enabled); + } + + reset(): void { + this.removeStoredValue(); + try { + globalThis.sessionStorage?.removeItem(DEMO_MEETING_LINKS_KEY); + } catch { + // Hosted demo links are unavailable unless demo tools and mode are both enabled. + } + this.enabledSubject.next(false); + } + + private readStoredValue(): boolean { + try { + return globalThis.sessionStorage?.getItem(DEMO_MODE_STORAGE_KEY) === 'true'; + } catch { + return false; + } + } + + private writeStoredValue(): void { + try { + globalThis.sessionStorage?.setItem(DEMO_MODE_STORAGE_KEY, 'true'); + } catch { + // Storage can be unavailable in private or hardened browser contexts. + } + } + + private removeStoredValue(): void { + try { + globalThis.sessionStorage?.removeItem(DEMO_MODE_STORAGE_KEY); + } catch { + // The in-memory state still fails closed when storage is unavailable. + } + } +} diff --git a/src/app/demo/demo-tools.guard.ts b/src/app/demo/demo-tools.guard.ts new file mode 100644 index 0000000000..bbc49aa88b --- /dev/null +++ b/src/app/demo/demo-tools.guard.ts @@ -0,0 +1,8 @@ +import {inject} from '@angular/core'; +import {CanActivateFn, Router} from '@angular/router'; +import {DemoModeStore} from './demo-mode.store'; + +export const demoToolsGuard: CanActivateFn = () => { + const demoMode = inject(DemoModeStore); + return demoMode.available ? true : inject(Router).createUrlTree(['/home']); +}; diff --git a/src/app/demo/demo-tools.module.ts b/src/app/demo/demo-tools.module.ts new file mode 100644 index 0000000000..c7275aed9b --- /dev/null +++ b/src/app/demo/demo-tools.module.ts @@ -0,0 +1,36 @@ +import {CommonModule} from '@angular/common'; +import {HTTP_INTERCEPTORS} from '@angular/common/http'; +import {NgModule} from '@angular/core'; +import {ReactiveFormsModule} from '@angular/forms'; +import {MatButtonModule} from '@angular/material/button'; +import {MatCardModule} from '@angular/material/card'; +import {MatIconModule} from '@angular/material/icon'; +import {MatSlideToggleModule} from '@angular/material/slide-toggle'; +import {RouterModule} from '@angular/router'; +import {TeamsMeetingComposerComponent} from '../unit-hub/teams-meeting-composer.component'; +import {DemoControlsComponent} from './demo-controls/demo-controls.component'; +import {DemoDataMaskInterceptor} from './demo-data-mask.interceptor'; +import {DemoModeBannerComponent} from './demo-mode-banner/demo-mode-banner.component'; + +@NgModule({ + declarations: [DemoControlsComponent, DemoModeBannerComponent], + imports: [ + CommonModule, + ReactiveFormsModule, + TeamsMeetingComposerComponent, + RouterModule, + MatButtonModule, + MatCardModule, + MatIconModule, + MatSlideToggleModule, + ], + exports: [DemoModeBannerComponent], + providers: [ + { + provide: HTTP_INTERCEPTORS, + useClass: DemoDataMaskInterceptor, + multi: true, + }, + ], +}) +export class DemoToolsModule {} diff --git a/src/app/demo/fixtures/peer-progress-demo.fixtures.ts b/src/app/demo/fixtures/peer-progress-demo.fixtures.ts new file mode 100644 index 0000000000..b0e8aa22d6 --- /dev/null +++ b/src/app/demo/fixtures/peer-progress-demo.fixtures.ts @@ -0,0 +1,207 @@ +import {PeerProgressState} from 'src/app/api/models/peer-progress'; +import {PeerProgressIndicator} from 'src/app/api/models/peer-progress-indicator'; +import {PeerProgressUnitSummary} from 'src/app/api/models/peer-progress-unit-summary'; + +export const DEMO_STATUS_DISTRIBUTION: PeerProgressIndicator['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}, +]; + +export type DemoPeerProgressState = + | 'normal' + | 'zero' + | 'suppressed' + | 'unavailable' + | 'stale' + | 'disabled'; + +const currentTimestamp = (): string => new Date().toISOString(); + +export const NORMAL_STATE: PeerProgressIndicator = { + taskDefinitionId: 0, + unitId: 0, + targetGrade: 0, + submittedPercentage: 60, + completedPercentage: 10, + distributionAvailable: true, + statusDistribution: DEMO_STATUS_DISTRIBUTION, + isUserEnabled: true, + isSuppressed: false, + isStale: false, + isFeatureEnabled: true, + lastUpdatedAt: currentTimestamp(), + unavailableMessage: '', + unavailableReason: null, + distributionUnavailableReason: null, +}; + +export const ROUNDED_90_STATE: PeerProgressIndicator = { + ...NORMAL_STATE, + statusDistribution: DEMO_STATUS_DISTRIBUTION.map((entry) => + entry.status === 'fail' ? {...entry, percentage: 0} : {...entry}, + ), +}; + +export const ROUNDED_110_STATE: PeerProgressIndicator = { + ...NORMAL_STATE, + statusDistribution: DEMO_STATUS_DISTRIBUTION.map((entry) => + entry.status === 'fail' ? {...entry, percentage: 20} : {...entry}, + ), +}; + +export const ZERO_PERCENT_STATE: PeerProgressIndicator = { + ...NORMAL_STATE, + submittedPercentage: 0, + completedPercentage: 0, + 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}, + ], +}; + +export const SUPPRESSED_STATE: PeerProgressIndicator = { + ...NORMAL_STATE, + submittedPercentage: null, + completedPercentage: null, + distributionAvailable: false, + statusDistribution: [], + isSuppressed: true, + unavailableMessage: 'Not enough students to show progress.', + unavailableReason: 'insufficient_cohort', + distributionUnavailableReason: 'insufficient_cohort', +}; + +export const UNAVAILABLE_STATE: PeerProgressIndicator = { + ...NORMAL_STATE, + submittedPercentage: null, + completedPercentage: null, + distributionAvailable: false, + statusDistribution: [], + unavailableMessage: 'Progress unavailable.', + unavailableReason: 'snapshot_unavailable', + distributionUnavailableReason: 'snapshot_unavailable', +}; + +export const STALE_STATE: PeerProgressIndicator = { + ...NORMAL_STATE, + submittedPercentage: null, + completedPercentage: null, + distributionAvailable: false, + statusDistribution: [], + isStale: true, + lastUpdatedAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 3).toISOString(), + unavailableMessage: 'Peer progress is currently unavailable.', + unavailableReason: 'stale', + distributionUnavailableReason: 'stale', +}; + +export const DISABLED_STATE: PeerProgressIndicator = { + ...NORMAL_STATE, + submittedPercentage: null, + completedPercentage: null, + distributionAvailable: false, + statusDistribution: [], + isFeatureEnabled: false, + unavailableMessage: 'Peer Progress Indicator is disabled for this unit.', + unavailableReason: 'feature_disabled', + distributionUnavailableReason: 'feature_disabled', +}; + +export const DETAIL_PROTECTED_STATE: PeerProgressIndicator = { + ...NORMAL_STATE, + submittedPercentage: 60, + completedPercentage: 10, + distributionAvailable: false, + statusDistribution: [], + distributionUnavailableReason: 'privacy_protection', +}; + +export const USER_DISABLED_STATE: PeerProgressIndicator = { + ...NORMAL_STATE, + submittedPercentage: null, + completedPercentage: null, + distributionAvailable: false, + statusDistribution: [], + isUserEnabled: false, + unavailableMessage: 'Peer progress is turned off in your profile settings.', + unavailableReason: 'user_disabled', + distributionUnavailableReason: 'user_disabled', +}; + +export const DEMO_WEEKLY_REMAINING: readonly number[] = [ + 1.0, 0.98, 0.93, 0.88, 0.8, 0.73, 0.66, 0.58, 0.51, 0.44, 0.36, 0.28, 0.2, 0.13, 0.07, 0.02, 0.0, +]; + +export const DEMO_PEER_MEDIAN_STATE: PeerProgressState = 'ready'; + +export function createDemoUnitSummary( + unitId: number, + targetGrade: number, + studentPercentage: number | null, + state: DemoPeerProgressState = 'normal', +): PeerProgressUnitSummary { + const cohort = demoPeerProgressState(state); + + return { + unitId, + targetGrade, + studentPercentage, + submittedPercentage: cohort.submittedPercentage, + isSuppressed: cohort.isSuppressed, + isStale: cohort.isStale, + isFeatureEnabled: cohort.isFeatureEnabled, + lastUpdatedAt: cohort.lastUpdatedAt, + unavailableMessage: cohort.unavailableMessage, + }; +} + +export function createMaskedPeerProgressIndicator(taskDefinitionId: number): PeerProgressIndicator { + return { + ...DISABLED_STATE, + taskDefinitionId, + unavailableMessage: 'Enable demo mode to show live peer comparison data.', + }; +} + +function demoPeerProgressState(state: DemoPeerProgressState): PeerProgressIndicator { + switch (state) { + case 'normal': + return NORMAL_STATE; + case 'zero': + return ZERO_PERCENT_STATE; + case 'suppressed': + return SUPPRESSED_STATE; + case 'unavailable': + return UNAVAILABLE_STATE; + case 'stale': + return STALE_STATE; + case 'disabled': + return DISABLED_STATE; + } +} diff --git a/src/app/demo/fixtures/push-preview.fixture.ts b/src/app/demo/fixtures/push-preview.fixture.ts new file mode 100644 index 0000000000..d7a388e97f --- /dev/null +++ b/src/app/demo/fixtures/push-preview.fixture.ts @@ -0,0 +1,6 @@ +export const DEMO_PUSH_PREVIEW = { + appName: 'OnTrack demo', + title: 'Feedback is ready', + message: 'New feedback is available for Demo Task 2.1P.', + destination: 'Demo task dashboard', +} as const; From edafa71e914dfe75751b6cc1eb1752916a19d19b Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Sun, 27 Sep 2026 22:58:49 +1000 Subject: [PATCH 2/3] feat(theme): page redesign for the demo files Brings the dark mode page redesign (ontrack-features-t2-2026/doubtfire-web#280, which relands ontrack-features-t2-2026/doubtfire-web#192) to the files this PR already carries, so every file stays in exactly one PR. Co-authored-by: Maple Fox --- src/app/demo/README.md | 43 +- .../demo-controls.component.html | 491 ++++++--------- .../demo-controls.component.scss | 578 ++++-------------- .../demo-controls.component.spec.ts | 289 +++++---- .../demo-controls/demo-controls.component.ts | 131 +--- .../demo/demo-data-mask.interceptor.spec.ts | 91 --- src/app/demo/demo-data-mask.interceptor.ts | 78 --- src/app/demo/demo-meeting-links.store.spec.ts | 3 + .../demo-mode-banner.component.html | 12 +- .../demo-mode-banner.component.scss | 27 +- .../demo-mode-banner.component.spec.ts | 50 +- .../demo-mode-banner.component.ts | 49 +- src/app/demo/demo-mode.store.spec.ts | 40 +- src/app/demo/demo-mode.store.ts | 48 +- src/app/demo/demo-tools.module.ts | 11 +- .../fixtures/peer-progress-demo.fixtures.ts | 36 +- 16 files changed, 690 insertions(+), 1287 deletions(-) delete mode 100644 src/app/demo/demo-data-mask.interceptor.spec.ts delete mode 100644 src/app/demo/demo-data-mask.interceptor.ts diff --git a/src/app/demo/README.md b/src/app/demo/README.md index 87b20e0e44..829b4fc43d 100644 --- a/src/app/demo/README.md +++ b/src/app/demo/README.md @@ -1,19 +1,30 @@ -# Centralised local demo mode +# Guarded local demo walkthrough -This folder owns the controls and shared state for the combined local walkthrough. Unit Hub's -sample content is in `src/app/unit-hub/unit-hub-demo.fixtures.ts`. Demo tools are available only when -`environment.enableDemoTools` is true and -`environment.production` is false. They start off and persist only for the current browser tab. +`DemoScenarioRegistryService` is the only runtime adapter for the combined local walkthrough. +It loads `GET /api/demo/scenario` after authentication and keeps the returned contract in its own +in-memory subject. It does not write units, projects, tasks, notifications, groups, or dynamic IDs +into any normal entity cache. -When demo mode is off in local development, the HTTP mask keeps `DEMO20007` (or one fallback -project), hides notification rows/counts, and the task PPI service returns a labelled masked state. -When it is on, those API-backed surfaces pass through unchanged. The unit-summary percentage, -burndown curve and push preview use fabricated presentation values. Unit Hub has its own explicit -demo fixtures: with the switch off, it always reads the signed-in account's authorised live API -content, including in a development build. +Unit Hub is separate: its sample content is in `src/app/unit-hub/unit-hub-demo.fixtures.ts`, not the +scenario contract. -The push preview is visual only. It must never inject `SwPush`, request `Notification` permission, -or call `/push_subscriptions`. +Two independent guards are required: the build must set `environment.enableDemoTools` in a +non-production build, and the API contract must succeed for the guarded synthetic account. An +ordinary development API, another account, or production returns a generic 404, leaving the tools +unavailable. + +Unit Hub has its own explicit demo fixtures. With demo mode off it always reads the signed-in +account's authorised live API content, including in a development build. + +Demo OFF is a true pass-through. There is no HTTP masking interceptor and no PPI substitution. +Demo ON only allows feature surfaces to consume the stable adapters in the contract. The enabled +bit is stored in `sessionStorage` under both the scenario ID and authenticated user ID, is reset on +sign out/account change, and never travels to the API. Toggling does not reload the application or +mutate server data. + +The canonical semantics and seed live in the API's +`lib/demo_data/mobile_feedback_scenario.rb`. Client-side preview fixture files may remain for unit +tests or later component work, but they are not the runtime scenario registry. ## Unit Hub walkthrough @@ -88,8 +99,10 @@ details are appended to these public links. Unit-specific content remains in the 3. Remove the Demo controls item/store injection from `common/header/header.component.*`. 4. Remove `` from `app.component.html`. 5. Remove `enableDemoTools` from both environment files. -6. Remove the `DemoModeStore.reset()` sign-out hook from `authentication.service.ts`. -7. Remove demo gating/imports from the PPI indicator, progress dashboard, and burndown chart. +6. Remove the registry load/clear and `DemoModeStore.reset()` sign-out hooks from + `authentication.service.ts`. +7. Remove demo gating/imports from the progress dashboard, burndown chart, and any later feature + adapters that consume `DemoScenarioRegistryService`. 8. Either delete the demo-only unit summary and peer-median UI or replace them with authorised live API adapters before retaining those surfaces. 9. Remove Unit Hub's demo fixture branch and hosted-link store dependency from `UnitHubService`, diff --git a/src/app/demo/demo-controls/demo-controls.component.html b/src/app/demo/demo-controls/demo-controls.component.html index 583466bfef..5a968a7ded 100644 --- a/src/app/demo/demo-controls/demo-controls.component.html +++ b/src/app/demo/demo-controls/demo-controls.component.html @@ -1,342 +1,197 @@
- - - - Demo controls - Local development only - - - -

- Demo mode combines the feature surfaces used for walkthroughs. It does not replace live CPD - task data or recommendation scores with fabricated values. -

- - - Demo mode {{ demoMode.enabled ? 'on' : 'off' }} - - -

- Changing this setting reloads OnTrack so project and notification caches cannot mix quiet - and demonstration data. -

- -

What the switch does

-
-
Off — quiet baseline
-
- Keeps one baseline unit (DEMO20007 when available) and hides the demo notification and - peer-comparison surfaces. -
-
On — complete walkthrough
-
- Shows the complete cross-unit scenario, a privacy-safe lifecycle spread (60% submitted and - 10% complete), and all seven curated local notifications. -
-
- -

Affected surfaces

-
    - @for (surface of affectedSurfaces; track surface) { -
  • {{ surface }}
  • - } -
- -
-

Unit Hub: announcements, HelpHubs and classes

+ @if (scenario$ | async; as scenario) { + + + + Demo walkthrough + Synthetic records in an isolated local runtime + + +

- Turn demo mode on above, then open Unit Hub to preview a student enrolled only in SIT111. - The sample includes announcements, two HelpHubs and a lecture. SIT102 content stays - hidden. + This walkthrough uses deterministic synthetic records. It never changes live student data, + and turning it off returns every normal API response unchanged.

-

- Demo dates and content are fictional. Subscriptions and editing are disabled. You can - export a calendar copy labelled DEMO. With demo mode off, Unit Hub reads real content for - your account. -

- - Open Unit Hub -
- Try the Teams meeting draft -

- This example opens a real Teams invitation draft with fictional details. OnTrack sends - nothing. In the normal version, assigned teaching staff prepare meetings from the - session editor using their own title, dates and description. -

- -
-
-
-
- +
-

Peer Progress Indicator visual preview

-

- Sample only — fabricated anonymous percentages. Switching previews never calls the API - or changes a profile. +

Walkthrough presentation

+

+ {{ + demoMode.enabled + ? 'OnTrack may highlight the fixture hooks listed below.' + : 'OnTrack shows this account’s normal server data without demo overrides.' + }}

-
- -
- @for (option of ppiPreviewOptions; track option.kind) { -
+ + -
-
-
- - - Peer Progress Indicator - Anonymous peers at your target grade - -
- - @if (!ppiPreview.isSuppressed) { - +
+

Unit Hub: announcements, HelpHubs and classes

+

+ Turn demo mode on above, then open Unit Hub to preview a student enrolled only in + SIT111. The sample includes announcements, two HelpHubs and a lecture. SIT102 content + stays hidden. +

+

+ Demo dates and content are fictional. Subscriptions and editing are turned off. You can + export a calendar copy labelled DEMO. With demo mode off, Unit Hub reads real content + for your account. +

+ Open Unit Hub + +
+ + - @if (ppiPreview.isSuppressed) { -
- - - Progress is hidden to protect privacy - {{ ppiPreview.unavailableMessage }} - +
+ Try the Teams meeting draft +

+ This example opens a real Teams invitation draft with fictional details. OnTrack sends + nothing. In the normal version, assigned teaching staff prepare meetings from the + session editor using their own title, dates and description. +

+ +
+
+ +
+
+
+

Task lifecycle fixture

+

+ {{ scenario.task_lifecycle.total_tasks }} tasks in + {{ scenario.primary_unit_key }} +

- } @else { -
- - {{ formatPpiPercentage(ppiPreviewPercentage) }}% - of peers have completed this task - - +
+
+
Submitted
+
{{ scenario.task_lifecycle.submitted_percentage }}%
+
+
+
Complete
+
{{ scenario.task_lifecycle.completed_percentage }}%
+
+
+
+
    + @for (status of scenario.task_lifecycle.statuses; track status.status) { +
  • + {{ status.percentage }}% + {{ statusLabel(status.status) }} + {{ status.task_abbreviations.join(', ') }} +
  • + } +
+
+ +
+

Peer progress hooks

+
    + @for (unit of scenario.units; track unit.key) { +
  • {{ unit.code }} {{ unit.name }} - - - - - - - @if (ppiPreviewAdvanced) { -
    -
    - Completed {{ ppiPreview.completedPercentage }}% - -
    - - - Task status breakdown - Independently privacy-rounded; values may not total 100% - - - @if (!ppiPreview.distributionAvailable) { -
    - - - Detailed breakdown protected - The overall percentage is safe to show, but detailed statuses are hidden to - prevent individual progress being inferred. - -
    - } @else if (ppiPreviewUsesStackedDistribution) { -
    - @for (segment of ppiPreviewSegments; track segment.status) { - - } -
    -
      - @for (segment of ppiPreviewSegments; track segment.status) { -
    • - - {{ segment.label }} - {{ formatPpiPercentage(segment.percentage) }}% -
    • - } -
    + @if (unit.ppi.state === 'available') { + + {{ unit.ppi.submitted_percentage }}% submitted · + {{ unit.ppi.completed_percentage }}% complete + } @else { -
    -

    - These privacy-rounded statuses total - {{ formatPpiPercentage(ppiPreviewDistributionTotal) }}%. Each bar uses its own - 0–100% scale, so values are not stretched to fill 100%. -

    -
      - @for (segment of ppiPreviewSegments; track segment.status) { -
    • - - - {{ segment.label }} - - - - - {{ formatPpiPercentage(segment.percentage) }}% -
    • - } -
    -
    + Unavailable — insufficient cohort } -
    +
  • } - } - - -

    - These preview states are local visual fixtures. Production always uses the authenticated, - privacy-filtered API response. -

    -
+ + -
-
- -
-

Push notification visual preview

-

Preview only — no browser permission or push subscription is changed.

-
-
+ - +
+

Reusable fixture adapters

+

+ {{ scenario.notification_hooks.length }} varied notification events and group + {{ scenario.group_hook.name }} ({{ scenario.group_hook.member_count }}/{{ + scenario.group_hook.capacity + }}) are ready for their feature walkthroughs. +

+
-

- This preview never calls Notification permission APIs, Angular SwPush, or - /push_subscriptions. +

+ Contract {{ scenario.scenario_id }} · schema {{ scenario.schema_version }}. Runtime IDs + stay in memory and are never copied into normal entity caches.

- - - - Open My Profile for real notifications - -
- - + + + } diff --git a/src/app/demo/demo-controls/demo-controls.component.scss b/src/app/demo/demo-controls/demo-controls.component.scss index 964d809d51..ced0601d51 100644 --- a/src/app/demo/demo-controls/demo-controls.component.scss +++ b/src/app/demo/demo-controls/demo-controls.component.scss @@ -1,40 +1,66 @@ .demo-controls { + box-sizing: border-box; margin: 0 auto; - max-width: 56rem; - padding: 2rem 1rem 4rem; + max-width: 64rem; + padding: 1.5rem 1rem 4rem; +} + +.hub-demo__open { + justify-self: start; } .hub-meeting-links { display: grid; gap: 0.75rem; min-width: 0; + padding-top: 1rem; + border-top: 1px solid var(--ot-color-divider); h3 { margin: 0; + font-size: 0.95rem; + } + + label { + font-size: 0.875rem; + font-weight: 600; } input { width: 100%; min-width: 0; box-sizing: border-box; - border: 1px solid var(--ot-color-border, #d4d4d8); - border-radius: 6px; + // Muted text, not the border token: that is 1.6:1 in dark, too faint for a field edge. + border: 1px solid var(--ot-color-text-muted); + border-radius: var(--ot-radius-sm); padding: 0.75rem; - background: var(--ot-color-surface, #fff); - color: var(--ot-color-text, #202124); + background: var(--ot-color-surface); + color: var(--ot-color-text); } } +.fixture-section .hub-meeting-links__error { + color: var(--ot-color-error); +} + .hub-meeting-links__actions { display: flex; flex-wrap: wrap; gap: 0.5rem; } +.hub-demo__draft summary { + min-height: 44px; + display: flex; + align-items: center; + cursor: pointer; + font-weight: 600; +} + mat-card-content { display: grid; - gap: 1rem; - padding-top: 1.5rem; + gap: 1.25rem; + padding-top: 1.25rem; } h2, @@ -45,518 +71,154 @@ dd { margin: 0; } -dl { - display: grid; - gap: 0.5rem 1rem; - grid-template-columns: max-content 1fr; +h2 { + font-size: 1.05rem; } -dt { - font-weight: 600; -} - -.push-preview { - border-top: 1px solid #d4d4d8; - display: grid; - gap: 1rem; - margin-top: 0.5rem; - padding-top: 1.5rem; -} - -.ppi-preview { - border-top: 1px solid #d4d4d8; - display: grid; - gap: 1rem; - margin-top: 0.5rem; - min-width: 0; - padding-top: 1.5rem; -} - -.ppi-preview__heading { - align-items: flex-start; - display: flex; - gap: 0.75rem; -} - -.ppi-preview__choices { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; -} - -.ppi-preview__choice--active { - background: #ecebff; - border-color: #3939ff; - color: #3029a5; -} - -.ppi-preview-card { - background: - radial-gradient(circle at 92% -10%, rgb(57 57 255 / 12%), transparent 34%), - linear-gradient(135deg, #ffffff, #fbfbff); - border: 1px solid #d9d9ef; - border-radius: 12px; - box-sizing: border-box; - box-shadow: 0 3px 12px rgb(29 25 78 / 8%); - container: ppi-preview-card / inline-size; - max-width: 100%; - min-width: 0; - overflow: hidden; +.demo-switch, +.fixture-section { + border: 1px solid var(--ot-color-border); + border-radius: 0.75rem; + padding: 1rem; } -.ppi-preview-card__header { +.demo-switch, +.fixture-section__heading { align-items: center; display: flex; - gap: 10px; + gap: 1rem; justify-content: space-between; - padding: 9px 12px 5px; -} - -.ppi-preview-card__identity { - align-items: center; - display: flex; - gap: 8px; - min-width: 0; - - > span:last-child { - min-width: 0; - } - - strong, - small { - display: block; - } - - strong { - font-size: 15px; - letter-spacing: -0.01em; - line-height: 1.25; - } - - small { - color: #52525f; - font-size: 11px; - line-height: 1.3; - margin-top: 1px; - } } -.ppi-preview-card__avatar { - align-items: center; - background: linear-gradient(145deg, #eeecff, #dcd9ff); - border: 1px solid #b7b1ff; - border-radius: 50%; - box-shadow: inset 0 0 0 3px rgb(255 255 255 / 58%); - color: #2d23b8; - display: inline-flex; - flex: 0 0 34px; - height: 34px; - justify-content: center; - - mat-icon { - font-size: 20px; - height: 20px; - width: 20px; - } -} - -.ppi-preview-card__toggle { - align-items: center; - appearance: none; - background: transparent; - border: 0; - border-radius: 999px; - color: #33333e; - cursor: pointer; - display: inline-flex; - flex: 0 0 auto; - font: inherit; - font-size: 11px; - font-weight: 600; - gap: 5px; - min-height: 28px; - padding: 3px 4px; - white-space: nowrap; - - &:focus-visible { - outline: 2px solid #3939ff; - outline-offset: 2px; - } -} - -.ppi-preview-card__toggle-track { - background: #737582; - border-radius: 999px; - display: inline-flex; - flex: 0 0 28px; - height: 15px; - padding: 2px; - transition: background 160ms ease; -} - -.ppi-preview-card__toggle-thumb { - background: #ffffff; - border-radius: 50%; - box-shadow: 0 1px 2px rgb(0 0 0 / 28%); - height: 11px; - transform: translateX(0); - transition: transform 160ms ease; - width: 11px; +.demo-switch p, +.fixture-section p, +.contract-note { + color: var(--ot-color-text-muted); + line-height: 1.45; } -.ppi-preview-card__toggle[aria-checked='true'] .ppi-preview-card__toggle-track { - background: #3939ff; +.walkthrough-links { + display: grid; + gap: 0.75rem; + grid-template-columns: repeat(4, minmax(0, 1fr)); } -.ppi-preview-card__toggle[aria-checked='true'] .ppi-preview-card__toggle-thumb { - transform: translateX(13px); +.walkthrough-links a { + min-height: 44px; + white-space: normal; } -.ppi-preview-card__summary { - align-items: center; +.fixture-section { display: grid; - gap: 12px; - grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.25fr); - padding: 2px 12px 10px 54px; + gap: 1rem; } -.ppi-preview-card__value { - align-items: baseline; +.fixture-metrics { display: flex; - gap: 6px; - min-width: 0; - - > span { - font-size: 12px; - font-weight: 600; - line-height: 1.25; - } - - strong { - color: #2d23b8; - font-size: 28px; - letter-spacing: -0.04em; - line-height: 1; - } + gap: 1rem; + text-align: right; } -.ppi-preview-card__visual { - min-width: 0; +.fixture-metrics dt { + color: var(--ot-color-text-muted); + font-size: 0.75rem; } -.ppi-preview-card__visual, -.ppi-preview-card__scale { - display: block; +.fixture-metrics dd { + font-size: 1.15rem; + font-weight: 700; } -.ppi-preview-card__track { - background: #e9e9ef; - border-radius: 999px; - box-shadow: inset 0 1px 2px rgb(0 0 0 / 10%); - display: block; - height: 9px; - overflow: hidden; - - span { - background: linear-gradient(90deg, #4637ff, #6559ff 62%, #766cfb); - border-radius: inherit; - box-shadow: 0 0 10px rgb(57 57 255 / 34%); - display: block; - height: 100%; - transition: width 260ms ease; - } -} - -.ppi-preview-card__scale { - color: #52525f; - display: flex; - font-size: 10px; - font-weight: 600; - justify-content: space-between; - margin-top: 3px; +.status-grid, +.hook-list { + list-style: none; + padding: 0; } -.ppi-preview-card__advanced { - background: rgb(246 246 255 / 76%); - border-top: 1px solid #e1e0f2; +.status-grid { display: grid; - gap: 9px; - padding: 10px 12px 12px; + gap: 0.5rem; + grid-template-columns: repeat(7, minmax(0, 1fr)); } -.ppi-preview-card__metrics { +.status-grid li { + background: var(--ot-color-surface-raised); + border-radius: 0.5rem; display: grid; - gap: 6px; - grid-template-columns: repeat(2, minmax(0, 1fr)); - - > span { - align-items: center; - background: #ffffff; - border: 1px solid #dedee8; - border-left: 4px solid; - border-radius: 6px; - display: flex; - font-size: 12px; - font-weight: 600; - gap: 12px; - justify-content: space-between; - min-width: 0; - padding: 5px 8px; - } - - strong { - font-size: 16px; - } - - .is-complete { - border-left-color: #45a049; - } - - .is-submitted { - border-left-color: #0079d8; - } -} - -.ppi-preview-card__advanced-heading, -.ppi-preview-card__advanced-heading small { - display: block; -} - -.ppi-preview-card__advanced-heading small { - color: #626272; - font-size: 11px; - margin-top: 2px; -} - -.ppi-preview-card__advanced-heading strong { - font-size: 14px; + gap: 0.2rem; + min-width: 0; + padding: 0.65rem; } -.ppi-preview-bar { - background: #e4e4e7; - border: 2px solid #ffffff; - border-radius: 999px; - box-shadow: 0 1px 4px rgb(0 0 0 / 18%); - display: flex; - height: 14px; - overflow: hidden; - - span { - flex-basis: 0; - - & + span { - border-left: 1px solid rgb(255 255 255 / 58%); - } - } +.status-grid span, +.status-grid small { + overflow-wrap: anywhere; } -.ppi-preview-legend { - display: grid; - gap: 4px 6px; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - list-style: none; - padding: 0; - - li { - align-items: center; - background: rgb(255 255 255 / 86%); - border: 1px solid #e2e2eb; - border-radius: 5px; - display: grid; - font-size: 10px; - gap: 5px; - grid-template-columns: 9px minmax(0, 1fr) auto; - min-height: 20px; - padding: 2px 4px; - } - - li > span:first-child { - border: 1px solid rgb(0 0 0 / 12%); - border-radius: 50%; - height: 9px; - width: 9px; - } - - .ppi-preview-legend__name { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } +.status-grid small { + color: var(--ot-color-text-muted); } -.ppi-preview-independent { +.hook-list { display: grid; - gap: 7px; - - > p { - background: #ffffff; - border-left: 3px solid #3939ff; - border-radius: 6px; - color: #52525f; - font-size: 11px; - line-height: 1.45; - padding: 5px 7px; - } - - > ul { - display: grid; - gap: 5px; - list-style: none; - padding: 0; - } - - li { - align-items: center; - display: grid; - font-size: 11px; - gap: 7px; - grid-template-columns: minmax(110px, 0.55fr) minmax(80px, 1fr) 34px; - } - - li > span:first-child { - align-items: center; - display: flex; - font-weight: 600; - gap: 7px; - min-width: 0; - } - - li > span:first-child > span { - border: 1px solid rgb(0 0 0 / 12%); - border-radius: 50%; - flex: 0 0 9px; - height: 9px; - width: 9px; - } - - li > strong { - text-align: right; - } -} - -.ppi-preview-independent__track { - background: #e4e4e7; - border-radius: 999px; - box-shadow: inset 0 1px 2px rgb(0 0 0 / 8%); - display: block; - height: 9px; - overflow: hidden; -} - -.ppi-preview-independent__fill { - border-radius: inherit; - display: block; - height: 100%; + gap: 0.5rem; } -.ppi-preview-card__safe-state { - align-items: flex-start; +.hook-list li { + align-items: center; + background: var(--ot-color-surface-raised); + border-radius: 0.5rem; display: flex; - gap: 12px; - min-height: 42px; - padding: 3px 12px 10px 54px; - - span, - strong { - display: block; - } -} - -.ppi-preview-card__advanced .ppi-preview-card__safe-state { - background: #fffaf0; - border: 1px solid #ead8a7; - border-radius: 8px; - color: #5f4a17; - font-size: 12px; - line-height: 1.45; - min-height: 0; - padding: 8px 9px; + gap: 1rem; + justify-content: space-between; + min-height: 44px; + padding: 0 0.75rem; } -.ppi-preview__safety { - color: #52525b; - font-size: 0.75rem; +.hook-list li[data-state='unavailable'] { + background: var(--ot-color-surface-raised); } -@container ppi-preview-card (width < 460px) { - .ppi-preview-card__header { - align-items: flex-start; - } - - .ppi-preview-card__summary, - .ppi-preview-card__safe-state { - padding-left: 12px; - } +.contract-note { + font-size: 0.8rem; } -@container ppi-preview-card (width < 350px) { - .ppi-preview-card__header { - flex-wrap: wrap; +@media (max-width: 760px) { + .demo-controls { + padding: 0.75rem 0.5rem 3rem; } - .ppi-preview-card__toggle { - margin-left: 42px; + .demo-switch, + .fixture-section__heading, + .hook-list li { + align-items: stretch; + flex-direction: column; } - .ppi-preview-card__value strong { - font-size: 25px; + .demo-switch mat-slide-toggle { + min-height: 44px; } - .ppi-preview-card__summary { - grid-template-columns: 1fr; + .walkthrough-links { + grid-template-columns: repeat(2, minmax(0, 1fr)); } - .ppi-preview-legend { - grid-template-columns: 1fr; + .fixture-metrics { + text-align: left; } - .ppi-preview-independent li { - align-items: start; - grid-template-columns: 1fr auto; + .status-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); } - .ppi-preview-independent__track { - grid-column: 1 / -1; - grid-row: 2; - } -} - -@media (prefers-reduced-motion: reduce) { - .ppi-preview-card__track span, - .ppi-preview-card__toggle-track, - .ppi-preview-card__toggle-thumb { - transition: none; + .hook-list li { + padding: 0.75rem; } } -.push-preview__heading { - align-items: flex-start; - display: flex; - gap: 0.75rem; -} - -.push-preview__notification { - background: #ffffff; - border: 1px solid #d4d4d8; - border-radius: 0.75rem; - box-shadow: 0 0.5rem 1.5rem rgb(0 0 0 / 12%); - display: grid; - gap: 0.25rem; - max-width: 30rem; - padding: 1rem; -} - -.push-preview__notification small, -.push-preview__safety { - color: #52525b; -} - -@media (width < 40rem) { - dl { +@media (max-width: 350px) { + .walkthrough-links, + .status-grid { grid-template-columns: 1fr; } - - dd:not(:last-child) { - margin-bottom: 0.5rem; - } } diff --git a/src/app/demo/demo-controls/demo-controls.component.spec.ts b/src/app/demo/demo-controls/demo-controls.component.spec.ts index 35cc140bf6..600125b011 100644 --- a/src/app/demo/demo-controls/demo-controls.component.spec.ts +++ b/src/app/demo/demo-controls/demo-controls.component.spec.ts @@ -1,86 +1,150 @@ -import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; -import {provideHttpClient} from '@angular/common/http'; -import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; import {ComponentFixture, TestBed} from '@angular/core/testing'; import {MatSlideToggleChange} from '@angular/material/slide-toggle'; import {NoopAnimationsModule} from '@angular/platform-browser/animations'; import {provideRouter} from '@angular/router'; -import {SwPush} from '@angular/service-worker'; +import {of} from 'rxjs'; +import {PeerProgressDisplayPreferenceService} from 'src/app/common/services/peer-progress-display-preference.service'; +import {DemoMeetingLinksStore} from '../demo-meeting-links.store'; import {DemoModeStore} from '../demo-mode.store'; +import {DemoScenarioContract, DemoScenarioRegistryService} from '../demo-scenario-registry.service'; import {DemoToolsModule} from '../demo-tools.module'; -import {DEMO_RELOAD, DemoControlsComponent} from './demo-controls.component'; +import {DemoControlsComponent} from './demo-controls.component'; + +const scenario: DemoScenarioContract = { + schema_version: 1, + scenario_id: 'mobile-feedback-v1', + demo_only: true, + generated_at: '2026-08-31T00:00:00Z', + primary_unit_key: 'DEMO10001', + units: [ + { + key: 'DEMO10001', + code: 'DEMO10001', + name: 'Foundations', + unit_id: 11, + project_id: 21, + ppi: { + state: 'available', + unavailable_reason: null, + task_abbreviation: 'DUE7', + task_definition_id: 31, + submitted_percentage: 60, + completed_percentage: 10, + status_distribution: [], + }, + }, + { + key: 'DEMO30243', + code: 'DEMO30243', + name: 'Professional Practice', + unit_id: 12, + project_id: 22, + ppi: { + state: 'unavailable', + unavailable_reason: 'insufficient_cohort', + task_abbreviation: 'DUE7', + task_definition_id: 32, + submitted_percentage: null, + completed_percentage: null, + status_distribution: null, + }, + }, + ], + task_lifecycle: { + unit_key: 'DEMO10001', + total_tasks: 10, + submitted_percentage: 60, + completed_percentage: 10, + statuses: [ + { + status: 'not_started', + count: 2, + percentage: 20, + task_abbreviations: ['FUTURE', 'OVERDUE'], + }, + { + status: 'working_on_it', + count: 2, + percentage: 20, + task_abbreviations: ['DUE3', 'WORK'], + }, + ], + }, + notification_hooks: Array.from({length: 7}, (_, index) => ({ + key: 'event-' + index, + id: index + 1, + event: 'event_' + index, + notification_type: 'task', + read: index > 3, + created_at: '2026-08-31T00:00:00Z', + link: '/notifications', + })), + group_hook: { + key: 'project-team', + unit_key: 'DEMO20007', + unit_id: 12, + project_id: 22, + group_set_id: 32, + group_id: 42, + name: 'Team Indigo', + member_count: 3, + capacity: 4, + route: '/projects/22/groups', + }, + walkthrough_links: [ + {key: 'tasks', label: 'Tasks and CPD', route: '/projects/21/dashboard'}, + { + key: 'ppi', + label: 'Peer Progress Indicator', + route: '/projects/21/dashboard/DUE7?walkthrough=ppi', + }, + {key: 'notifications', label: 'Notifications', route: '/notifications'}, + ], +}; describe('DemoControlsComponent', () => { let fixture: ComponentFixture; - let demoMode: { - available: boolean; - enabled: boolean; - setEnabled: ReturnType; + let demoMode: {available: boolean; enabled: boolean; setEnabled: ReturnType}; + let meetingLinks: { + links: {helpHub: string; extraHelpHub: string}; + save: ReturnType; + clear: ReturnType; }; - let reload: ReturnType; - let requestPermission: ReturnType; - let requestSubscription: ReturnType; - let httpTesting: HttpTestingController; - let originalNotification: typeof Notification | undefined; beforeEach(async () => { demoMode = {available: true, enabled: false, setEnabled: vi.fn()}; - reload = vi.fn(); - requestPermission = vi.fn(); - requestSubscription = vi.fn(); - originalNotification = globalThis.Notification; - Object.defineProperty(globalThis, 'Notification', { - configurable: true, - value: {permission: 'default', requestPermission}, - }); - + meetingLinks = {links: {helpHub: '', extraHelpHub: ''}, save: vi.fn(), clear: vi.fn()}; await TestBed.configureTestingModule({ imports: [DemoToolsModule, NoopAnimationsModule], providers: [ {provide: DemoModeStore, useValue: demoMode}, - {provide: DEMO_RELOAD, useValue: reload}, - {provide: SwPush, useValue: {requestSubscription}}, + {provide: DemoMeetingLinksStore, useValue: meetingLinks}, + {provide: DemoScenarioRegistryService, useValue: {scenario$: of(scenario), scenario}}, + { + provide: PeerProgressDisplayPreferenceService, + useValue: {enabled: false, setEnabled: (enabled: boolean) => enabled}, + }, provideRouter([]), - provideHttpClient(), - provideHttpClientTesting(), ], }).compileComponents(); fixture = TestBed.createComponent(DemoControlsComponent); fixture.detectChanges(); - httpTesting = TestBed.inject(HttpTestingController); - }); - - afterEach(() => { - httpTesting.verify(); - Object.defineProperty(globalThis, 'Notification', { - configurable: true, - value: originalNotification, - }); }); - it('shows an accessible control and an exact description of affected surfaces', () => { + it('explains isolation and renders exact registry hooks', () => { const text = fixture.nativeElement.textContent; - const toggle = fixture.nativeElement.querySelector('mat-slide-toggle'); - - expect(toggle.getAttribute('aria-describedby')).toBe('demo-mode-description'); - expect(text).toContain('Demo mode off'); - expect(text).toContain('Off — quiet baseline'); - expect(text).toContain('one baseline unit (DEMO20007 when available)'); - expect(text).toContain('On — complete walkthrough'); - expect(text).toContain('privacy-safe lifecycle spread (60% submitted and 10% complete)'); - expect(text).toContain('all seven curated local notifications'); - expect(text).toContain('live local project cards'); - expect(text).toContain('live notification bell'); - expect(text).toContain('Live task-level peer comparison'); - expect(text).toContain('unit summary'); - expect(text).toContain('Peer Progress Indicator visual preview'); - expect(text).toContain('Full status data'); - expect(text).toContain('Insufficient cohort'); - expect(text).toContain('Advanced details protected'); - expect(text).toContain('Rounded total 90%'); - expect(text).toContain('Rounded total 110%'); - expect(fixture.nativeElement.querySelectorAll('.ppi-preview__choices button').length).toBe(5); + expect(text).toContain('Synthetic records in an isolated local runtime'); + expect(text).toContain('returns every normal API response unchanged'); + expect(text).toContain('10 tasks in'); + expect(text).toContain('DEMO10001'); + expect(text).toContain('60%'); + expect(text).toContain('10%'); + expect(text).toContain('Unavailable — insufficient cohort'); + expect(text).toContain('7 varied notification events'); + expect(text).toContain('Team Indigo (3/4)'); + expect(fixture.nativeElement.querySelector('[data-status="not_started"]')).toBeTruthy(); }); it('keeps the named demo section inside the app-owned main landmark', () => { @@ -88,97 +152,62 @@ describe('DemoControlsComponent', () => { expect(element.querySelector('main, [role="main"]')).toBeNull(); const section = element.querySelector('section.demo-controls'); expect(section.getAttribute('aria-labelledby')).toBe('demo-controls-title'); - expect(section.querySelector('#demo-controls-title').textContent.trim()).toBe('Demo controls'); + expect(section.querySelector('#demo-controls-title').textContent.trim()).toBe( + 'Demo walkthrough', + ); }); - it('persists the switch and reloads to avoid mixed entity caches', () => { + it('toggles presentation without reloading or mutating entity caches', () => { fixture.componentInstance.setDemoMode({checked: true} as MatSlideToggleChange); - expect(demoMode.setEnabled).toHaveBeenCalledWith(true); - expect(reload).toHaveBeenCalledOnce(); }); - it('renders a visual-only push preview without browser, SwPush, or API calls', () => { - const text = fixture.nativeElement.textContent; - - expect(text).toContain('Preview only'); - expect(text).toContain('Feedback is ready'); - expect(text).toContain('Open My Profile for real notifications'); + it('provides stable walkthrough link selectors', () => { + expect(fixture.nativeElement.querySelector('[data-testid="demo-link-tasks"]')).toBeTruthy(); + expect(fixture.nativeElement.querySelector('[data-testid="demo-link-ppi"]')).toBeTruthy(); expect( - fixture.nativeElement.querySelector('a[href="/edit_profile"]').getAttribute('href'), - ).toBe('/edit_profile'); - expect(requestPermission).not.toHaveBeenCalled(); - expect(requestSubscription).not.toHaveBeenCalled(); - httpTesting.expectNone((request) => request.url.includes('/push_subscriptions')); + fixture.nativeElement.querySelector('[data-testid="demo-link-notifications"]'), + ).toBeTruthy(); }); - it('switches between complete and privacy-safe peer progress preview states locally', () => { - fixture.componentInstance.setPpiPreviewAdvanced(true); - fixture.detectChanges(); - expect(fixture.nativeElement.textContent).toContain('Task status breakdown'); - expect(fixture.nativeElement.textContent).toContain('60%'); - expect(fixture.nativeElement.textContent).toContain('10%'); + it('links to the Unit Hub walkthrough and offers the Teams meeting draft', () => { + const host: HTMLElement = fixture.nativeElement; + const section = host.querySelector('[aria-labelledby="hub-demo-title"]'); + expect(section?.textContent).toContain('Unit Hub: announcements, HelpHubs and classes'); + expect(section?.querySelector('a[href="/unit-hub"]')?.textContent).toContain('Open Unit Hub'); + expect(section?.querySelector('f-teams-meeting-composer')).toBeTruthy(); + }); - fixture.componentInstance.setPpiPreview('details-protected'); - fixture.componentInstance.setPpiPreviewAdvanced(true); - fixture.detectChanges(); - expect(fixture.nativeElement.textContent).toContain('Detailed breakdown protected'); - expect(fixture.nativeElement.textContent).not.toContain('Redo'); + it('saves hosted meeting links for this tab and reports a rejected link', () => { + const component = fixture.componentInstance; + const host: HTMLElement = fixture.nativeElement; + const link = 'https://teams.microsoft.com/meet/123456789'; + component.meetingLinksForm.setValue({helpHub: link, extraHelpHub: ''}); - fixture.componentInstance.setPpiPreview('insufficient'); + host.querySelector('.hub-meeting-links button[type="submit"]').click(); fixture.detectChanges(); - expect(fixture.nativeElement.textContent).toContain('Progress is hidden to protect privacy'); - expect(fixture.nativeElement.textContent).toContain('Not enough students'); - - httpTesting.expectNone((request) => request.url.includes('/peer_progress')); - }); - - it('keeps the sample compact by default and exposes details through a small accessible switch', () => { - const preview = fixture.nativeElement.querySelector('.ppi-preview-card') as HTMLElement; - const toggle = preview.querySelector('button[role="switch"]') as HTMLButtonElement; - const progress = preview.querySelector('[role="progressbar"]') as HTMLElement; - expect(toggle.getAttribute('aria-label')).toBe('Advanced peer status breakdown'); - expect(toggle.getAttribute('aria-controls')).toBe('ppi-preview-advanced-panel'); - expect(toggle.getAttribute('aria-checked')).toBe('false'); - expect(toggle.getAttribute('aria-expanded')).toBe('false'); - expect(preview.querySelector('.ppi-preview-card__advanced')).toBeNull(); - expect(progress.getAttribute('aria-label')).toBe( - '10% of peers at your target grade have completed this task', + expect(meetingLinks.save).toHaveBeenCalledWith({helpHub: link, extraHelpHub: ''}); + expect(host.querySelector('.hub-meeting-links [role="status"]')?.textContent).toContain( + 'Saved for this browser tab', ); - toggle.click(); + meetingLinks.save.mockImplementation(() => { + throw new Error('Use a Teams joining link.'); + }); + component.saveMeetingLinks(); fixture.detectChanges(); + expect(host.querySelector('.hub-meeting-links [role="alert"]')?.textContent).toContain( + 'Use a Teams joining link.', + ); - expect(toggle.getAttribute('aria-checked')).toBe('true'); - expect(toggle.getAttribute('aria-expanded')).toBe('true'); - expect(preview.querySelector('#ppi-preview-advanced-panel')).toBeTruthy(); - expect(preview.textContent).toContain('Redo'); - expect(preview.textContent).toContain('Resubmit'); + component.clearMeetingLinks(); + expect(meetingLinks.clear).toHaveBeenCalled(); + expect(component.meetingLinksForm.getRawValue()).toEqual({helpHub: '', extraHelpHub: ''}); }); - it.each([ - {kind: 'rounded-90' as const, total: 90}, - {kind: 'rounded-110' as const, total: 110}, - ])('previews a $total% rounded vector without visually renormalising it', ({kind, total}) => { - fixture.componentInstance.setPpiPreview(kind); - fixture.componentInstance.setPpiPreviewAdvanced(true); - fixture.detectChanges(); - - const independent = fixture.nativeElement.querySelector('.ppi-preview-independent'); - const workingFill = fixture.nativeElement.querySelector( - '[data-status="working_on_it"] .ppi-preview-independent__fill', - ) as HTMLElement; - const workingTrack = fixture.nativeElement.querySelector( - '[data-status="working_on_it"] [role="progressbar"]', - ) as HTMLElement; - - expect(fixture.componentInstance.ppiPreviewDistributionTotal).toBe(total); - expect(fixture.nativeElement.querySelector('.ppi-preview-bar')).toBeNull(); - expect(independent.textContent).toContain(`total ${total}%`); - expect(independent.textContent).toContain('not stretched to fill 100%'); - expect(workingFill.style.width).toBe('20%'); - expect(workingTrack.getAttribute('aria-valuenow')).toBe('20'); - expect(workingTrack.getAttribute('aria-describedby')).toBe('ppi-preview-independent-note'); + it('includes the Batch 10 peer progress preview without duplicating the scenario registry', () => { + expect(fixture.nativeElement.querySelector('f-ppi-preview')).toBeTruthy(); + expect(fixture.nativeElement.querySelector('[data-testid="ppi-preview-full"]')).toBeTruthy(); }); }); diff --git a/src/app/demo/demo-controls/demo-controls.component.ts b/src/app/demo/demo-controls/demo-controls.component.ts index 18be520b63..6d4131fee0 100644 --- a/src/app/demo/demo-controls/demo-controls.component.ts +++ b/src/app/demo/demo-controls/demo-controls.component.ts @@ -1,34 +1,11 @@ -import {ChangeDetectionStrategy, Component, Inject, InjectionToken} from '@angular/core'; +import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormControl, FormGroup} from '@angular/forms'; import {MatSlideToggleChange} from '@angular/material/slide-toggle'; -import {PeerProgressIndicator} from 'src/app/api/models/peer-progress-indicator'; -import {TaskStatus, TaskStatusEnum} from 'src/app/api/models/task-status'; import {TeamsMeetingDraft} from '../../unit-hub/teams-meeting-draft'; import {unitHubDemo} from '../../unit-hub/unit-hub-demo.fixtures'; import {DemoMeetingLinksStore} from '../demo-meeting-links.store'; import {DemoModeStore} from '../demo-mode.store'; -import { - DETAIL_PROTECTED_STATE, - NORMAL_STATE, - ROUNDED_90_STATE, - ROUNDED_110_STATE, - SUPPRESSED_STATE, -} from '../fixtures/peer-progress-demo.fixtures'; -import {DEMO_PUSH_PREVIEW} from '../fixtures/push-preview.fixture'; - -type PpiPreviewKind = 'full' | 'rounded-90' | 'rounded-110' | 'insufficient' | 'details-protected'; - -interface PpiPreviewSegment { - status: TaskStatusEnum; - label: string; - color: string; - percentage: number; -} - -export const DEMO_RELOAD: InjectionToken<() => void> = new InjectionToken('DEMO_RELOAD', { - providedIn: 'root', - factory: () => () => globalThis.location?.reload(), -}); +import {DemoScenarioRegistryService} from '../demo-scenario-registry.service'; @Component({ selector: 'f-demo-controls', @@ -38,6 +15,8 @@ export const DEMO_RELOAD: InjectionToken<() => void> = new InjectionToken('DEMO_ standalone: false, }) export class DemoControlsComponent { + readonly scenario$ = this.registry.scenario$; + readonly demoMeetingDraft: TeamsMeetingDraft = { ...unitHubDemo().sessions[0], title: 'DEMO · HelpHub meeting draft', @@ -50,37 +29,19 @@ export class DemoControlsComponent { }); meetingLinksError = ''; meetingLinksStatus = ''; - readonly pushPreview = DEMO_PUSH_PREVIEW; - readonly ppiPreviewOptions: {kind: PpiPreviewKind; label: string}[] = [ - {kind: 'full', label: 'Full status data'}, - {kind: 'rounded-90', label: 'Rounded total 90%'}, - {kind: 'rounded-110', label: 'Rounded total 110%'}, - {kind: 'insufficient', label: 'Insufficient cohort'}, - {kind: 'details-protected', label: 'Advanced details protected'}, - ]; - - ppiPreviewKind: PpiPreviewKind = 'full'; - ppiPreviewAdvanced = false; - private readonly ppiStatusDisplayIndex = new Map( - TaskStatus.PEER_PROGRESS_DISPLAY_ORDER.map((status, index) => [status, index]), - ); - - readonly affectedSurfaces = [ - 'Unit Hub: fictional SIT111 announcements and sessions, with SIT102 excluded and API writes disabled', - 'All live local project cards, tasks, deadline warnings, filters, and recommendation scores', - 'The live notification bell, unread count, and notification history', - 'Live task-level peer comparison data returned by the local API', - 'A privacy-safe lifecycle spread (60% submitted and 10% complete), unit summary, and anonymous burndown sample', - ]; constructor( readonly demoMode: DemoModeStore, - @Inject(DEMO_RELOAD) private reload: () => void, + readonly registry: DemoScenarioRegistryService, private meetingLinks: DemoMeetingLinksStore, ) { this.meetingLinksForm.setValue(this.meetingLinks.links); } + setDemoMode(change: MatSlideToggleChange): void { + this.demoMode.setEnabled(change.checked); + } + saveMeetingLinks(): void { this.meetingLinksError = ''; this.meetingLinksStatus = ''; @@ -101,74 +62,10 @@ export class DemoControlsComponent { this.meetingLinksStatus = 'Hosted links removed. Sample meetings will be disabled.'; } - setDemoMode(change: MatSlideToggleChange): void { - if (change.checked === this.demoMode.enabled) { - return; - } - - this.demoMode.setEnabled(change.checked); - this.reload(); - } - - setPpiPreview(kind: PpiPreviewKind): void { - this.ppiPreviewKind = kind; - this.ppiPreviewAdvanced = false; - } - - setPpiPreviewAdvanced(advanced: boolean): void { - this.ppiPreviewAdvanced = advanced; - } - - get ppiPreview(): PeerProgressIndicator { - switch (this.ppiPreviewKind) { - case 'insufficient': - return SUPPRESSED_STATE; - case 'details-protected': - return DETAIL_PROTECTED_STATE; - case 'rounded-90': - return ROUNDED_90_STATE; - case 'rounded-110': - return ROUNDED_110_STATE; - default: - return NORMAL_STATE; - } - } - - get ppiPreviewPercentage(): number { - return this.ppiPreview.completedPercentage ?? this.ppiPreview.submittedPercentage ?? 0; - } - - get ppiPreviewWidth(): string { - return `${Math.min(100, Math.max(0, this.ppiPreviewPercentage))}%`; - } - - get ppiPreviewSegments(): PpiPreviewSegment[] { - return this.ppiPreview.statusDistribution - .filter((entry) => entry.percentage > 0) - .sort( - (left, right) => - (this.ppiStatusDisplayIndex.get(left.status) ?? Number.MAX_SAFE_INTEGER) - - (this.ppiStatusDisplayIndex.get(right.status) ?? Number.MAX_SAFE_INTEGER), - ) - .map((entry) => ({ - ...entry, - label: - entry.status === 'ready_for_feedback' - ? 'Ready for Feedback' - : (TaskStatus.STATUS_LABELS.get(entry.status) ?? entry.status), - color: TaskStatus.STATUS_COLORS.get(entry.status) ?? '#64748b', - })); - } - - get ppiPreviewDistributionTotal(): number { - return this.ppiPreviewSegments.reduce((total, segment) => total + segment.percentage, 0); - } - - get ppiPreviewUsesStackedDistribution(): boolean { - return this.ppiPreviewDistributionTotal === 100; - } - - formatPpiPercentage(value: number): string { - return Number.isInteger(value) ? `${value}` : value.toFixed(1).replace(/\.0$/, ''); + statusLabel(status: string): string { + return status + .split('_') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); } } diff --git a/src/app/demo/demo-data-mask.interceptor.spec.ts b/src/app/demo/demo-data-mask.interceptor.spec.ts deleted file mode 100644 index 7531b42331..0000000000 --- a/src/app/demo/demo-data-mask.interceptor.spec.ts +++ /dev/null @@ -1,91 +0,0 @@ -import {afterEach, beforeEach, describe, expect, it} from 'vitest'; -import { - HTTP_INTERCEPTORS, - HttpClient, - provideHttpClient, - withInterceptorsFromDi, -} 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 { - DemoDataMaskInterceptor, - filterProjectCollectionForQuietMode, -} from './demo-data-mask.interceptor'; -import {DemoModeStore} from './demo-mode.store'; - -describe('DemoDataMaskInterceptor', () => { - let http: HttpClient; - let httpTesting: HttpTestingController; - let demoMode: {shouldMaskApiData: boolean}; - - beforeEach(() => { - demoMode = {shouldMaskApiData: true}; - - TestBed.configureTestingModule({ - providers: [ - {provide: DemoModeStore, useValue: demoMode}, - { - provide: HTTP_INTERCEPTORS, - useClass: DemoDataMaskInterceptor, - multi: true, - }, - provideHttpClient(withInterceptorsFromDi()), - provideHttpClientTesting(), - ], - }); - - http = TestBed.inject(HttpClient); - httpTesting = TestBed.inject(HttpTestingController); - }); - - afterEach(() => { - httpTesting.verify(); - }); - - it('retains only DEMO20007 from the live projects response while demo mode is off', () => { - let result: unknown; - http.get(`${API_URL}/projects`).subscribe((value) => (result = value)); - - httpTesting.expectOne(`${API_URL}/projects`).flush([ - {id: 1, unit: {code: 'COS10001'}}, - {id: 2, unit: {code: 'DEMO20007'}}, - {id: 3, unit: {code: 'COS30003'}}, - ]); - - expect(result).toEqual([{id: 2, unit: {code: 'DEMO20007'}}]); - }); - - it('falls back to one project for a legacy local dataset', () => { - expect( - filterProjectCollectionForQuietMode([ - {id: 1, unit: {code: 'COS10001'}}, - {id: 2, unit: {code: 'COS20002'}}, - ]), - ).toEqual([{id: 1, unit: {code: 'COS10001'}}]); - }); - - it('masks notification rows and unread count without calling the backend', () => { - let notifications: unknown; - let unread: unknown; - - http.get(`${API_URL}/notifications/`).subscribe((value) => (notifications = value)); - http.get(`${API_URL}/notifications/unread_count`).subscribe((value) => (unread = value)); - - httpTesting.expectNone(`${API_URL}/notifications/`); - httpTesting.expectNone(`${API_URL}/notifications/unread_count`); - expect(notifications).toEqual([]); - expect(unread).toEqual({count: 0}); - }); - - it('passes API-backed data through unchanged when demo mode is on or unavailable', () => { - demoMode.shouldMaskApiData = false; - let result: unknown; - http.get(`${API_URL}/notifications/`).subscribe((value) => (result = value)); - - const live = [{id: 8, message: 'Live notification'}]; - httpTesting.expectOne(`${API_URL}/notifications/`).flush(live); - - expect(result).toEqual(live); - }); -}); diff --git a/src/app/demo/demo-data-mask.interceptor.ts b/src/app/demo/demo-data-mask.interceptor.ts deleted file mode 100644 index 36423e743a..0000000000 --- a/src/app/demo/demo-data-mask.interceptor.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { - HttpEvent, - HttpHandler, - HttpInterceptor, - HttpRequest, - HttpResponse, -} from '@angular/common/http'; -import {Injectable} from '@angular/core'; -import {Observable, map, of} from 'rxjs'; -import {DemoModeStore} from './demo-mode.store'; - -const DEMO_UNIT_CODE = 'DEMO20007'; - -type JsonRecord = Record; - -export function filterProjectCollectionForQuietMode(body: unknown): unknown { - if (Array.isArray(body)) { - return filterProjects(body); - } - - if (isRecord(body) && Array.isArray(body.data)) { - return {...body, data: filterProjects(body.data)}; - } - - return body; -} - -function filterProjects(projects: unknown[]): unknown[] { - const preferred = projects.filter((project) => projectUnitCode(project) === DEMO_UNIT_CODE); - return preferred.length > 0 ? preferred : projects.slice(0, 1); -} - -function projectUnitCode(project: unknown): string | null { - if (!isRecord(project)) { - return null; - } - - const unit = isRecord(project.unit) ? project.unit : null; - const value = unit?.code ?? project.unit_code ?? project.unitCode; - return typeof value === 'string' ? value.toUpperCase() : null; -} - -function isRecord(value: unknown): value is JsonRecord { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -@Injectable() -export class DemoDataMaskInterceptor implements HttpInterceptor { - constructor(private demoMode: DemoModeStore) {} - - intercept(request: HttpRequest, next: HttpHandler): Observable> { - if (!this.demoMode.shouldMaskApiData) { - return next.handle(request); - } - - if (request.method === 'GET' && /\/api\/notifications\/?$/.test(request.url)) { - return of(new HttpResponse({body: [], status: 200, url: request.urlWithParams})); - } - - if (request.method === 'GET' && /\/api\/notifications\/unread_count\/?$/.test(request.url)) { - return of(new HttpResponse({body: {count: 0}, status: 200, url: request.urlWithParams})); - } - - if (request.method === 'GET' && /\/api\/projects\/?$/.test(request.url)) { - return next - .handle(request) - .pipe( - map((event) => - event instanceof HttpResponse - ? event.clone({body: filterProjectCollectionForQuietMode(event.body)}) - : event, - ), - ); - } - - return next.handle(request); - } -} diff --git a/src/app/demo/demo-meeting-links.store.spec.ts b/src/app/demo/demo-meeting-links.store.spec.ts index fefee5c114..a10b3200e8 100644 --- a/src/app/demo/demo-meeting-links.store.spec.ts +++ b/src/app/demo/demo-meeting-links.store.spec.ts @@ -65,8 +65,11 @@ describe('Host-provided demo meeting links', () => { it('retains links across mode toggles but clears them when authentication resets demo state', () => { const store = new DemoMeetingLinksStore(true); const mode = new DemoModeStore(true); + // Demo mode only switches on for a loaded scenario, as it does after sign-in. + mode.configureScenario('links-spec', 1); store.save({helpHub: hosted, extraHelpHub: ''}); mode.setEnabled(true); + expect(mode.enabled).toBe(true); mode.setEnabled(false); expect(store.links.helpHub).toBe(hosted); mode.reset(); diff --git a/src/app/demo/demo-mode-banner/demo-mode-banner.component.html b/src/app/demo/demo-mode-banner/demo-mode-banner.component.html index d81e59f1d9..ce63ef58fc 100644 --- a/src/app/demo/demo-mode-banner/demo-mode-banner.component.html +++ b/src/app/demo/demo-mode-banner/demo-mode-banner.component.html @@ -1,8 +1,14 @@ @if (demoMode.enabled$ | async) { -