diff --git a/src/app/demo/README.md b/src/app/demo/README.md new file mode 100644 index 0000000000..29f1cfb3d7 --- /dev/null +++ b/src/app/demo/README.md @@ -0,0 +1,104 @@ +# Guarded local demo walkthrough + +`DemoScenarioRegistryService` controls eligibility for the combined local walkthrough and supplies its seeded runtime adapters. +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. + +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. + +Demo OFF is a true pass-through. There is no HTTP masking interceptor and no PPI substitution. +Demo ON enables the seeded feature adapters from the contract and the explicitly isolated Unit Hub sample described below. 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`. The seeded task, notification, group and peer-progress adapters use that contract. Unit Hub separately uses browser-only sample announcements and schedules after the same guards pass; these samples never enter the runtime registry or normal entity caches. + +## Unit Hub walkthrough + +First sign in to the guarded synthetic account and wait for the scenario contract to load. Enable **Demo mode** in **Demo controls**, then open **Unit Hub**. The switch applies immediately without a reload or another sign-in. 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 anywhere on an announcement or session card, including its blank space, for full details. +Separate Join, original-source and calendar controls keep their own actions. 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 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`, + 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..d27dcb2560 --- /dev/null +++ b/src/app/demo/demo-controls/demo-controls.component.html @@ -0,0 +1,224 @@ +
+ @if (scenario$ | async; as scenario) { + + + + Demo walkthrough + Synthetic records in an isolated local runtime + + + +

+ This walkthrough uses deterministic synthetic records. It never changes live student data, + and turning it off returns every normal API response unchanged. +

+ +
+
+

Walkthrough presentation

+

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

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

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 + + + +
+ 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 }} +

+
+
+
+
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 (unit.ppi.state === 'available') { + + {{ unit.ppi.submitted_percentage }}% submitted · + {{ unit.ppi.completed_percentage }}% complete + + } @else { + Unavailable — insufficient cohort + } +
  • + } +
+
+ + + +
+
+ +
+

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 + +
+ +
+

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. +

+
+ +

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

+
+
+ } +
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..5be50a50b3 --- /dev/null +++ b/src/app/demo/demo-controls/demo-controls.component.scss @@ -0,0 +1,264 @@ +:host { + display: block; + color: var(--ot-color-text); +} + +.demo-controls { + box-sizing: border-box; + margin: 0 auto; + 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; + // 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); + 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: 1.25rem; + padding-top: 1.25rem; +} + +h2, +p, +ul, +dl, +dd { + margin: 0; +} + +h2 { + font-size: 1.05rem; +} + +.demo-switch, +.fixture-section { + border: 1px solid var(--ot-color-border); + border-radius: 0.75rem; + padding: 1rem; +} + +.demo-switch, +.fixture-section__heading { + align-items: center; + display: flex; + gap: 1rem; + justify-content: space-between; +} + +.demo-switch p, +.fixture-section p, +.contract-note { + color: var(--ot-color-text-muted); + line-height: 1.45; +} + +.walkthrough-links { + display: grid; + gap: 0.75rem; + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.walkthrough-links a { + min-height: 44px; + white-space: normal; +} + +.fixture-section { + display: grid; + gap: 1rem; +} + +.fixture-metrics { + display: flex; + gap: 1rem; + text-align: right; +} + +.fixture-metrics dt { + color: var(--ot-color-text-muted); + font-size: 0.75rem; +} + +.fixture-metrics dd { + font-size: 1.15rem; + font-weight: 700; +} + +.status-grid, +.hook-list { + list-style: none; + padding: 0; +} + +.status-grid { + display: grid; + gap: 0.5rem; + grid-template-columns: repeat(7, minmax(0, 1fr)); +} + +.status-grid li { + background: var(--ot-color-surface-raised); + border-radius: 0.5rem; + display: grid; + gap: 0.2rem; + min-width: 0; + padding: 0.65rem; +} + +.status-grid span, +.status-grid small { + overflow-wrap: anywhere; +} + +.status-grid small { + color: var(--ot-color-text-muted); +} + +.hook-list { + display: grid; + gap: 0.5rem; +} + +.hook-list li { + align-items: center; + background: var(--ot-color-surface-raised); + border-radius: 0.5rem; + display: flex; + gap: 1rem; + justify-content: space-between; + min-height: 44px; + padding: 0 0.75rem; +} + +.hook-list li[data-state='unavailable'] { + background: var(--ot-color-surface-raised); +} + +.contract-note { + font-size: 0.8rem; +} + +@media (max-width: 760px) { + .demo-controls { + padding: 0.75rem 0.5rem 3rem; + } + + .demo-switch, + .fixture-section__heading, + .hook-list li { + align-items: stretch; + flex-direction: column; + } + + .demo-switch mat-slide-toggle { + min-height: 44px; + } + + .walkthrough-links { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .fixture-metrics { + text-align: left; + } + + .status-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .hook-list li { + padding: 0.75rem; + } +} + +@media (max-width: 350px) { + .walkthrough-links, + .status-grid { + grid-template-columns: 1fr; + } +} + +.push-preview { + border-top: 1px solid var(--ot-color-border); + display: grid; + gap: 1rem; + margin-top: 0.5rem; + padding-top: 1.5rem; +} + +.push-preview__heading { + align-items: flex-start; + display: flex; + gap: 0.75rem; +} + +.push-preview__notification { + color: var(--ot-color-text); + background: var(--ot-color-surface); + border: 1px solid var(--ot-color-border); + border-radius: 0.75rem; + box-shadow: var(--ot-elevation-1); + display: grid; + gap: 0.25rem; + max-width: 30rem; + padding: 1rem; +} + +.push-preview__notification small, +.push-preview__safety { + color: var(--ot-color-text-muted); +} + +.hook-list li[data-state='unavailable'] { + border-left: 3px solid var(--ot-color-warning); +} 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..548394610c --- /dev/null +++ b/src/app/demo/demo-controls/demo-controls.component.spec.ts @@ -0,0 +1,223 @@ +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 {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 {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 meetingLinks: { + links: {helpHub: string; extraHelpHub: string}; + save: ReturnType; + clear: ReturnType; + }; + + beforeEach(async () => { + demoMode = {available: true, enabled: false, setEnabled: vi.fn()}; + meetingLinks = {links: {helpHub: '', extraHelpHub: ''}, save: vi.fn(), clear: vi.fn()}; + await TestBed.configureTestingModule({ + imports: [DemoToolsModule, NoopAnimationsModule], + providers: [ + {provide: DemoModeStore, useValue: demoMode}, + {provide: DemoMeetingLinksStore, useValue: meetingLinks}, + {provide: DemoScenarioRegistryService, useValue: {scenario$: of(scenario), scenario}}, + { + provide: PeerProgressDisplayPreferenceService, + useValue: {enabled: false, setEnabled: (enabled: boolean) => enabled}, + }, + provideRouter([]), + ], + }).compileComponents(); + + fixture = TestBed.createComponent(DemoControlsComponent); + fixture.detectChanges(); + }); + + it('explains isolation and renders exact registry hooks', () => { + const text = fixture.nativeElement.textContent; + 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', () => { + 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 walkthrough', + ); + }); + + it('toggles presentation without reloading or mutating entity caches', () => { + fixture.componentInstance.setDemoMode({checked: true} as MatSlideToggleChange); + expect(demoMode.setEnabled).toHaveBeenCalledWith(true); + }); + + it('retains the Unit Hub host controls and visual-only push preview', () => { + const text = fixture.nativeElement.textContent; + expect(text).toContain('Unit Hub: announcements, HelpHubs and classes'); + expect(text).toContain('Use your own HelpHub links'); + expect(text).toContain('Preview only'); + expect(text).toContain('Feedback is ready'); + expect(fixture.nativeElement.querySelector('a[href="/unit-hub"]')).toBeTruthy(); + expect(fixture.nativeElement.querySelector('a[href="/edit_profile"]')).toBeTruthy(); + }); + + 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('[data-testid="demo-link-notifications"]'), + ).toBeTruthy(); + }); + + 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(); + }); + + 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: ''}); + + host.querySelector('.hub-meeting-links button[type="submit"]').click(); + fixture.detectChanges(); + + expect(meetingLinks.save).toHaveBeenCalledWith({helpHub: link, extraHelpHub: ''}); + expect(host.querySelector('.hub-meeting-links [role="status"]')?.textContent).toContain( + 'Saved for this browser tab', + ); + + 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.', + ); + + component.clearMeetingLinks(); + expect(meetingLinks.clear).toHaveBeenCalled(); + expect(component.meetingLinksForm.getRawValue()).toEqual({helpHub: '', extraHelpHub: ''}); + }); + + 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 new file mode 100644 index 0000000000..84ada598b7 --- /dev/null +++ b/src/app/demo/demo-controls/demo-controls.component.ts @@ -0,0 +1,73 @@ +import {ChangeDetectionStrategy, Component} from '@angular/core'; +import {FormControl, FormGroup} from '@angular/forms'; +import {MatSlideToggleChange} from '@angular/material/slide-toggle'; +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 {DemoScenarioRegistryService} from '../demo-scenario-registry.service'; +import {DEMO_PUSH_PREVIEW} from '../fixtures/push-preview.fixture'; + +@Component({ + selector: 'f-demo-controls', + templateUrl: './demo-controls.component.html', + styleUrls: ['./demo-controls.component.scss'], + changeDetection: ChangeDetectionStrategy.Eager, + standalone: false, +}) +export class DemoControlsComponent { + readonly scenario$ = this.registry.scenario$; + + 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; + + constructor( + readonly demoMode: DemoModeStore, + 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 = ''; + 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.'; + } + + statusLabel(status: string): string { + return status + .split('_') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + } +} 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..f459a2b2b9 --- /dev/null +++ b/src/app/demo/demo-meeting-links.store.spec.ts @@ -0,0 +1,89 @@ +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); + // Demo mode only switches on for a loaded scenario, as it does after sign-in. + mode.configureScenario('all-features', 111); + store.save({helpHub: hosted, extraHelpHub: ''}); + mode.setEnabled(true); + expect(mode.enabled).toBe(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..ce63ef58fc --- /dev/null +++ b/src/app/demo/demo-mode-banner/demo-mode-banner.component.html @@ -0,0 +1,14 @@ +@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..2c4f5fc87c --- /dev/null +++ b/src/app/demo/demo-mode-banner/demo-mode-banner.component.scss @@ -0,0 +1,31 @@ +.demo-mode-banner { + align-items: center; + background: var(--ot-status-working-on-it); + border-bottom: 1px solid var(--ot-color-border); + color: var(--ot-status-working-on-it-on); + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + justify-content: center; + padding: 0.375rem 1rem; +} + +// The text link sits on the banner fill, so it takes the banner's on-colour. The +// outlined Exit button keeps its own control fill and label colour. +.demo-mode-banner a { + color: inherit; + min-height: 44px; +} + +@media (max-width: 600px) { + .demo-mode-banner { + gap: 0.35rem 0.65rem; + justify-content: flex-start; + padding: 0.35rem 0.75rem; + } + + .demo-mode-banner span { + flex: 1 1 calc(100% - 3rem); + line-height: 1.3; + } +} 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..0129ddb36d --- /dev/null +++ b/src/app/demo/demo-mode-banner/demo-mode-banner.component.spec.ts @@ -0,0 +1,68 @@ +import {beforeEach, describe, expect, it, onTestFinished, 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 {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 setEnabled: ReturnType; + + beforeEach(async () => { + enabled = new BehaviorSubject(true); + setEnabled = vi.fn((value: boolean) => enabled.next(value)); + + await TestBed.configureTestingModule({ + imports: [DemoToolsModule, NoopAnimationsModule], + providers: [ + { + provide: DemoModeStore, + useValue: {enabled$: enabled.asObservable(), setEnabled}, + }, + provideRouter([]), + ], + }).compileComponents(); + + fixture = TestBed.createComponent(DemoModeBannerComponent); + fixture.detectChanges(); + }); + + it('uses understandable synthetic-data wording and stable selectors', () => { + expect(fixture.nativeElement.textContent).toContain('Demo walkthrough on.'); + expect(fixture.nativeElement.textContent).toContain('synthetic local data'); + expect(fixture.nativeElement.querySelector('[data-testid="demo-mode-banner"]')).toBeTruthy(); + expect(fixture.nativeElement.querySelector('[data-testid="demo-controls-link"]')).toBeTruthy(); + }); + + it('exits without reloading the application', () => { + fixture.componentInstance.exitDemo(); + expect(setEnabled).toHaveBeenCalledWith(false); + }); + + it('publishes its height for full-height screens and clears it when it goes', () => { + const bannerHeight = () => + document.documentElement.style.getPropertyValue('--ot-demo-banner-height'); + // jsdom lays nothing out, so give the banner a height to report. + const heightSpy = vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(49); + onTestFinished(() => heightSpy.mockRestore()); + const measured = TestBed.createComponent(DemoModeBannerComponent); + measured.detectChanges(); + + expect(bannerHeight()).toBe('49px'); + + enabled.next(false); + measured.detectChanges(); + + expect(bannerHeight()).toBe('0px'); + + enabled.next(true); + measured.detectChanges(); + measured.destroy(); + + expect(bannerHeight()).toBe('0px'); + }); +}); 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..7d210acbcf --- /dev/null +++ b/src/app/demo/demo-mode-banner/demo-mode-banner.component.ts @@ -0,0 +1,59 @@ +import {DOCUMENT} from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + ElementRef, + Inject, + OnDestroy, + ViewChild, +} from '@angular/core'; +import {DemoModeStore} from '../demo-mode.store'; + +const BANNER_HEIGHT_PROPERTY = '--ot-demo-banner-height'; + +@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 implements OnDestroy { + private resizeObserver?: ResizeObserver; + + constructor( + readonly demoMode: DemoModeStore, + @Inject(DOCUMENT) private document: Document, + ) {} + + // Full-height screens size themselves from the viewport minus a fixed header + // allowance. The banner sits above the header and wraps to several lines on a + // phone, so it publishes its measured height for those screens to take off too. + @ViewChild('banner') + set banner(ref: ElementRef | undefined) { + this.resizeObserver?.disconnect(); + this.resizeObserver = undefined; + + const element = ref?.nativeElement; + this.publishHeight(element?.offsetHeight ?? 0); + if (!element || typeof ResizeObserver === 'undefined') { + return; + } + + this.resizeObserver = new ResizeObserver(() => this.publishHeight(element.offsetHeight)); + this.resizeObserver.observe(element); + } + + ngOnDestroy(): void { + this.resizeObserver?.disconnect(); + this.publishHeight(0); + } + + exitDemo(): void { + this.demoMode.setEnabled(false); + } + + private publishHeight(height: number): void { + this.document.documentElement.style.setProperty(BANNER_HEIGHT_PROPERTY, `${height}px`); + } +} 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..ed85ad9483 --- /dev/null +++ b/src/app/demo/demo-mode.store.spec.ts @@ -0,0 +1,52 @@ +import {beforeEach, describe, expect, it} from 'vitest'; +import {DEMO_MODE_STORAGE_KEY, DemoModeStore} from './demo-mode.store'; + +describe('DemoModeStore', () => { + beforeEach(() => sessionStorage.clear()); + + it('is unavailable until the guarded scenario contract is loaded', () => { + const store = new DemoModeStore(true); + + expect(store.available).toBe(false); + expect(store.enabled).toBe(false); + }); + + it('stores the enabled state under the user and scenario namespace', () => { + const firstStore = new DemoModeStore(true); + firstStore.configureScenario('mobile-feedback-v1', 42); + firstStore.setEnabled(true); + + const key = DEMO_MODE_STORAGE_KEY + ':mobile-feedback-v1:uid42'; + expect(sessionStorage.getItem(key)).toBe('true'); + + const restoredStore = new DemoModeStore(true); + restoredStore.configureScenario('mobile-feedback-v1', 42); + expect(restoredStore.enabled).toBe(true); + + const otherUserStore = new DemoModeStore(true); + otherUserStore.configureScenario('mobile-feedback-v1', 43); + expect(otherUserStore.enabled).toBe(false); + }); + + it('disables without changing scenario availability and clears fully on sign out', () => { + const store = new DemoModeStore(true); + store.configureScenario('mobile-feedback-v1', 42); + store.setEnabled(true); + + store.reset(); + expect(store.available).toBe(true); + expect(store.enabled).toBe(false); + + store.clearScenario(); + expect(store.available).toBe(false); + }); + + it('cannot configure or enable when demo tools are not compiled in', () => { + const store = new DemoModeStore(false); + store.configureScenario('mobile-feedback-v1', 42); + store.setEnabled(true); + + expect(store.available).toBe(false); + expect(store.enabled).toBe(false); + }); +}); diff --git a/src/app/demo/demo-mode.store.ts b/src/app/demo/demo-mode.store.ts new file mode 100644 index 0000000000..636aecd660 --- /dev/null +++ b/src/app/demo/demo-mode.store.ts @@ -0,0 +1,107 @@ +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; + private scenarioId: string | null = null; + private userId: number | null = null; + + readonly enabled$: Observable; + + constructor(@Inject(DEMO_TOOLS_AVAILABLE) private readonly toolsEligible: boolean) { + this.enabledSubject = new BehaviorSubject(false); + this.enabled$ = this.enabledSubject.asObservable().pipe(distinctUntilChanged()); + } + + get available(): boolean { + return this.toolsEligible && this.scenarioId !== null && this.userId !== null; + } + + get enabled(): boolean { + return this.available && this.enabledSubject.value; + } + + configureScenario(scenarioId: string, userId: number): void { + if (!this.toolsEligible) { + return; + } + + this.scenarioId = scenarioId; + this.userId = userId; + this.enabledSubject.next(this.readStoredValue()); + } + + 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); + } + + clearScenario(): void { + this.reset(); + this.scenarioId = null; + this.userId = null; + } + + private readStoredValue(): boolean { + try { + return globalThis.sessionStorage?.getItem(this.storageKey) === 'true'; + } catch { + return false; + } + } + + private writeStoredValue(): void { + try { + globalThis.sessionStorage?.setItem(this.storageKey, 'true'); + } catch { + // Storage can be unavailable in private or hardened browser contexts. + } + } + + private removeStoredValue(): void { + try { + if (this.scenarioId !== null && this.userId !== null) { + globalThis.sessionStorage?.removeItem(this.storageKey); + } + } catch { + // The in-memory state still fails closed when storage is unavailable. + } + } + + private get storageKey(): string { + return `${DEMO_MODE_STORAGE_KEY}:${this.scenarioId}:uid${this.userId}`; + } +} 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..44d0cc2593 --- /dev/null +++ b/src/app/demo/demo-tools.module.ts @@ -0,0 +1,29 @@ +import {CommonModule} from '@angular/common'; +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 {DemoModeBannerComponent} from './demo-mode-banner/demo-mode-banner.component'; +import {PpiPreviewComponent} from './ppi-preview/ppi-preview.component'; + +@NgModule({ + declarations: [DemoControlsComponent, DemoModeBannerComponent], + imports: [ + CommonModule, + ReactiveFormsModule, + TeamsMeetingComposerComponent, + RouterModule, + MatButtonModule, + MatCardModule, + MatIconModule, + MatSlideToggleModule, + PpiPreviewComponent, + ], + exports: [DemoModeBannerComponent], +}) +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..ca6926b1c6 --- /dev/null +++ b/src/app/demo/fixtures/peer-progress-demo.fixtures.ts @@ -0,0 +1,227 @@ +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, + submittedPercentage: 50, + completedPercentage: 20, + statusDistribution: DEMO_STATUS_DISTRIBUTION.map((entry) => { + switch (entry.status) { + case 'ready_for_feedback': + return {...entry, percentage: 10}; + case 'complete': + return {...entry, percentage: 20}; + case 'fail': + return {...entry, percentage: 0}; + default: + return {...entry}; + } + }), +}; + +export const ROUNDED_110_STATE: PeerProgressIndicator = { + ...NORMAL_STATE, + submittedPercentage: 80, + completedPercentage: 30, + statusDistribution: DEMO_STATUS_DISTRIBUTION.map((entry) => { + switch (entry.status) { + case 'not_started': + return {...entry, percentage: 10}; + case 'complete': + return {...entry, percentage: 30}; + default: + return {...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: 70, + completedPercentage: 30, + 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;