From e1f6db5d5afd3bcd90750f44d847924cf2368abb Mon Sep 17 00:00:00 2001 From: Clupai8o0 Date: Mon, 28 Sep 2026 00:29:01 +1000 Subject: [PATCH] feat(celebrate): celebration and motion components Adds src/app/common/celebrate from ontrack-features-t2-2026/doubtfire-web#281: the submission, portfolio and milestone celebrations and the reduced motion helpers. The ui, submissions and redesign PRs open them, so this one merges before those. --- src/app/common/celebrate/_motion.scss | 60 ++ .../celebrate/animated-check.component.html | 35 ++ .../celebrate/animated-check.component.scss | 106 ++++ .../animated-check.component.spec.ts | 64 +++ .../celebrate/animated-check.component.ts | 41 ++ .../celebration-particles.component.html | 13 + .../celebration-particles.component.scss | 44 ++ .../celebration-particles.component.ts | 57 ++ .../milestone-celebration.service.ts | 243 ++++++++ .../celebrate/milestone-dialog.component.html | 95 ++++ .../celebrate/milestone-dialog.component.scss | 269 +++++++++ .../milestone-dialog.component.spec.ts | 213 +++++++ .../celebrate/milestone-dialog.component.ts | 93 ++++ .../motion-demo/motion-demo.component.html | 196 +++++++ .../motion-demo/motion-demo.component.scss | 408 ++++++++++++++ .../motion-demo/motion-demo.component.ts | 251 +++++++++ .../submit-flow-lab.component.html | 35 ++ .../submit-flow-lab.component.scss | 527 ++++++++++++++++++ .../motion-demo/submit-flow-lab.component.ts | 123 ++++ .../motion-demo/submit-motion.component.html | 60 ++ .../motion-demo/submit-motion.component.scss | 113 ++++ .../motion-demo/submit-motion.component.ts | 104 ++++ .../portfolio-celebration.component.html | 35 ++ .../portfolio-celebration.component.scss | 236 ++++++++ .../portfolio-celebration.component.ts | 116 ++++ .../portfolio-celebration.service.spec.ts | 73 +++ .../portfolio-celebration.service.ts | 96 ++++ src/app/common/celebrate/reduced-motion.ts | 12 + .../submission-celebration.component.html | 22 + .../submission-celebration.component.scss | 157 ++++++ .../submission-celebration.component.ts | 37 ++ .../submission-celebration.service.ts | 134 +++++ .../celebrate/submission-timing.spec.ts | 139 +++++ src/app/common/celebrate/submission-timing.ts | 96 ++++ .../task-status-seen.service.spec.ts | 107 ++++ .../celebrate/task-status-seen.service.ts | 126 +++++ 36 files changed, 4536 insertions(+) create mode 100644 src/app/common/celebrate/_motion.scss create mode 100644 src/app/common/celebrate/animated-check.component.html create mode 100644 src/app/common/celebrate/animated-check.component.scss create mode 100644 src/app/common/celebrate/animated-check.component.spec.ts create mode 100644 src/app/common/celebrate/animated-check.component.ts create mode 100644 src/app/common/celebrate/celebration-particles.component.html create mode 100644 src/app/common/celebrate/celebration-particles.component.scss create mode 100644 src/app/common/celebrate/celebration-particles.component.ts create mode 100644 src/app/common/celebrate/milestone-celebration.service.ts create mode 100644 src/app/common/celebrate/milestone-dialog.component.html create mode 100644 src/app/common/celebrate/milestone-dialog.component.scss create mode 100644 src/app/common/celebrate/milestone-dialog.component.spec.ts create mode 100644 src/app/common/celebrate/milestone-dialog.component.ts create mode 100644 src/app/common/celebrate/motion-demo/motion-demo.component.html create mode 100644 src/app/common/celebrate/motion-demo/motion-demo.component.scss create mode 100644 src/app/common/celebrate/motion-demo/motion-demo.component.ts create mode 100644 src/app/common/celebrate/motion-demo/submit-flow-lab.component.html create mode 100644 src/app/common/celebrate/motion-demo/submit-flow-lab.component.scss create mode 100644 src/app/common/celebrate/motion-demo/submit-flow-lab.component.ts create mode 100644 src/app/common/celebrate/motion-demo/submit-motion.component.html create mode 100644 src/app/common/celebrate/motion-demo/submit-motion.component.scss create mode 100644 src/app/common/celebrate/motion-demo/submit-motion.component.ts create mode 100644 src/app/common/celebrate/portfolio-celebration.component.html create mode 100644 src/app/common/celebrate/portfolio-celebration.component.scss create mode 100644 src/app/common/celebrate/portfolio-celebration.component.ts create mode 100644 src/app/common/celebrate/portfolio-celebration.service.spec.ts create mode 100644 src/app/common/celebrate/portfolio-celebration.service.ts create mode 100644 src/app/common/celebrate/reduced-motion.ts create mode 100644 src/app/common/celebrate/submission-celebration.component.html create mode 100644 src/app/common/celebrate/submission-celebration.component.scss create mode 100644 src/app/common/celebrate/submission-celebration.component.ts create mode 100644 src/app/common/celebrate/submission-celebration.service.ts create mode 100644 src/app/common/celebrate/submission-timing.spec.ts create mode 100644 src/app/common/celebrate/submission-timing.ts create mode 100644 src/app/common/celebrate/task-status-seen.service.spec.ts create mode 100644 src/app/common/celebrate/task-status-seen.service.ts diff --git a/src/app/common/celebrate/_motion.scss b/src/app/common/celebrate/_motion.scss new file mode 100644 index 0000000000..526dfb7c0d --- /dev/null +++ b/src/app/common/celebrate/_motion.scss @@ -0,0 +1,60 @@ +// Motion tokens for milestone moments. Celebrations are rare, so they may take +// a little longer than everyday UI, but every single piece stays under 300ms and +// a whole sequence finishes inside about 1.2s. Exits are quicker than entries. + +$ease-out: cubic-bezier(0.23, 1, 0.32, 1); +$ease-in-out: cubic-bezier(0.77, 0, 0.175, 1); + +$duration-exit: 150ms; +$duration-enter: 220ms; +$duration-draw: 280ms; +$stagger: 50ms; + +// Expose the same values as custom properties on a host, so templates can set +// per-item delays with calc() against one source of truth. +@mixin tokens { + --ease-out: #{$ease-out}; + --ease-in-out: #{$ease-in-out}; + --celebrate-duration-exit: #{$duration-exit}; + --celebrate-duration-enter: #{$duration-enter}; + --celebrate-duration-draw: #{$duration-draw}; + --celebrate-stagger: #{$stagger}; +} + +@mixin reduced-motion { + @media (prefers-reduced-motion: reduce) { + @content; + } +} + +@mixin press { + transition: transform 120ms $ease-out; + + &:active { + transform: scale(0.97); + } +} + +// A soft rise used by labels and rows. Never starts from nothing: a small offset +// and a near-full scale, so it reads as settling in place. +@keyframes celebrate-rise { + from { + opacity: 0; + transform: translateY(6px); + } + + to { + opacity: 1; + transform: none; + } +} + +@keyframes celebrate-fade { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} diff --git a/src/app/common/celebrate/animated-check.component.html b/src/app/common/celebrate/animated-check.component.html new file mode 100644 index 0000000000..6c6c8e1c86 --- /dev/null +++ b/src/app/common/celebrate/animated-check.component.html @@ -0,0 +1,35 @@ + diff --git a/src/app/common/celebrate/animated-check.component.scss b/src/app/common/celebrate/animated-check.component.scss new file mode 100644 index 0000000000..ef0990e50e --- /dev/null +++ b/src/app/common/celebrate/animated-check.component.scss @@ -0,0 +1,106 @@ +@use './motion' as motion; + +:host { + @include motion.tokens; + + --check-color: var(--ot-color-success); + // Multiplies every duration and delay below. A rare, full-surface moment is + // worth drawing more slowly than the same mark inside a toast. + --check-pace: 1; + display: inline-block; + flex: none; + line-height: 0; + color: var(--check-color); +} + +:host(.tone-primary) { + --check-color: var(--ot-color-primary); +} + +:host(.tone-neutral) { + --check-color: var(--ot-color-text-muted); +} + +svg { + display: block; + overflow: visible; + // The small settle after the tick lands. Crisp, not bouncy: 6% at most. + animation: check-settle calc(240ms * var(--check-pace)) motion.$ease-out both; + animation-delay: calc(var(--check-delay, 0ms) + 520ms * var(--check-pace)); +} + +.ring-track { + stroke: currentColor; + opacity: 0.16; +} + +.ring, +.tick { + stroke: currentColor; + stroke-dasharray: 1; + stroke-dashoffset: 1; +} + +.ring { + stroke-linecap: round; + animation: check-draw calc(#{motion.$duration-draw} * var(--check-pace)) motion.$ease-out forwards; + animation-delay: var(--check-delay, 0ms); +} + +.tick { + animation: check-draw calc(240ms * var(--check-pace)) motion.$ease-out forwards; + animation-delay: calc(var(--check-delay, 0ms) + 260ms * var(--check-pace)); +} + +@keyframes check-draw { + to { + stroke-dashoffset: 0; + } +} + +@keyframes check-settle { + 0% { + transform: scale(0.95); + } + + 60% { + transform: scale(1.06); + } + + 100% { + transform: scale(1); + } +} + +@mixin static-end-state { + svg { + animation: celebrate-check-fade 120ms linear both; + animation-delay: var(--check-delay, 0ms); + } + + .ring, + .tick { + animation: none; + stroke-dashoffset: 0; + } +} + +@keyframes celebrate-check-fade { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +:host(.reduced-motion) { + @include static-end-state; +} + +@include motion.reduced-motion { + :host { + @include static-end-state; + } +} diff --git a/src/app/common/celebrate/animated-check.component.spec.ts b/src/app/common/celebrate/animated-check.component.spec.ts new file mode 100644 index 0000000000..f92a4722e0 --- /dev/null +++ b/src/app/common/celebrate/animated-check.component.spec.ts @@ -0,0 +1,64 @@ +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {TestBed} from '@angular/core/testing'; +import {AnimatedCheckComponent} from './animated-check.component'; +import {CelebrationParticlesComponent} from './celebration-particles.component'; + +describe('AnimatedCheckComponent reduced motion', () => { + const original = window.matchMedia; + + const mockMatchMedia = (reduce: boolean) => { + (window as unknown as {matchMedia: unknown}).matchMedia = vi.fn((query: string) => ({ + matches: reduce && query === '(prefers-reduced-motion: reduce)', + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })); + }; + + afterEach(() => { + (window as unknown as {matchMedia: unknown}).matchMedia = original; + TestBed.resetTestingModule(); + }); + + it('draws normally when the viewer has not asked for less motion', () => { + mockMatchMedia(false); + const fixture = TestBed.createComponent(AnimatedCheckComponent); + fixture.detectChanges(); + + expect(fixture.componentInstance.reducedMotion).toBe(false); + expect(fixture.nativeElement.classList.contains('reduced-motion')).toBe(false); + }); + + it('exposes the reduced motion flag and marks the host for the static end state', () => { + mockMatchMedia(true); + const fixture = TestBed.createComponent(AnimatedCheckComponent); + fixture.componentRef.setInput('tone', 'primary'); + fixture.componentRef.setInput('size', 24); + fixture.detectChanges(); + + const host = fixture.nativeElement as HTMLElement; + expect(fixture.componentInstance.reducedMotion).toBe(true); + expect(host.classList.contains('reduced-motion')).toBe(true); + expect(host.classList.contains('tone-primary')).toBe(true); + expect(host.querySelector('svg')?.getAttribute('aria-hidden')).toBe('true'); + }); + + it('falls back to full motion when matchMedia is unavailable', () => { + (window as unknown as {matchMedia: unknown}).matchMedia = undefined; + const fixture = TestBed.createComponent(AnimatedCheckComponent); + + expect(fixture.componentInstance.reducedMotion).toBe(false); + }); + + it('renders no particles under reduced motion', () => { + mockMatchMedia(true); + const reduced = TestBed.createComponent(CelebrationParticlesComponent); + reduced.detectChanges(); + expect(reduced.nativeElement.querySelectorAll('.particle').length).toBe(0); + + mockMatchMedia(false); + const full = TestBed.createComponent(CelebrationParticlesComponent); + full.detectChanges(); + expect(full.nativeElement.querySelectorAll('.particle').length).toBe(12); + }); +}); diff --git a/src/app/common/celebrate/animated-check.component.ts b/src/app/common/celebrate/animated-check.component.ts new file mode 100644 index 0000000000..5fb51bb79f --- /dev/null +++ b/src/app/common/celebrate/animated-check.component.ts @@ -0,0 +1,41 @@ +import {ChangeDetectionStrategy, Component, computed, input} from '@angular/core'; +import {prefersReducedMotion} from './reduced-motion'; + +export type CelebrateTone = 'success' | 'primary' | 'neutral'; + +/** + * A ring that draws itself, then a tick that draws inside it. Purely decorative, + * so it is hidden from assistive technology and the host announces the meaning. + */ +@Component({ + selector: 'f-animated-check', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './animated-check.component.html', + styleUrl: './animated-check.component.scss', + host: { + 'class': 'f-animated-check', + '[class.tone-success]': "tone() === 'success'", + '[class.tone-primary]': "tone() === 'primary'", + '[class.tone-neutral]': "tone() === 'neutral'", + '[class.reduced-motion]': 'reducedMotion', + '[style.--check-delay]': 'delayCss()', + '[style.--check-pace]': 'pace()', + '[style.width.px]': 'size()', + '[style.height.px]': 'size()', + }, +}) +export class AnimatedCheckComponent { + public readonly size = input(40); + public readonly tone = input('success'); + /** Milliseconds to wait before the ring starts drawing. */ + public readonly delay = input(0); + /** Multiplies every duration in the draw. Above 1 draws more slowly. */ + public readonly pace = input(1); + + /** Read once. A viewer who wants less motion gets the finished tick with a quick fade. */ + public readonly reducedMotion = prefersReducedMotion(); + + protected readonly delayCss = computed(() => `${Math.max(0, this.delay())}ms`); + protected readonly strokeWidth = computed(() => (this.size() <= 20 ? 5 : 3.5)); +} diff --git a/src/app/common/celebrate/celebration-particles.component.html b/src/app/common/celebrate/celebration-particles.component.html new file mode 100644 index 0000000000..a3a4efcc50 --- /dev/null +++ b/src/app/common/celebrate/celebration-particles.component.html @@ -0,0 +1,13 @@ +@if (!reducedMotion) { + @for (particle of particles(); track $index) { + + } +} diff --git a/src/app/common/celebrate/celebration-particles.component.scss b/src/app/common/celebrate/celebration-particles.component.scss new file mode 100644 index 0000000000..01484a1a3e --- /dev/null +++ b/src/app/common/celebrate/celebration-particles.component.scss @@ -0,0 +1,44 @@ +@use './motion' as motion; + +:host { + position: absolute; + inset: 0; + display: block; + overflow: visible; + pointer-events: none; +} + +.particle { + position: absolute; + top: 50%; + left: 50%; + border-radius: 999px; + opacity: 0; + transform: translate(-50%, -50%) rotate(var(--angle)) translateY(0) scale(0.9); + animation: particle-burst 620ms motion.$ease-out forwards; + animation-delay: calc(var(--burst-delay, 0ms) + var(--particle-delay, 0ms)); + will-change: transform, opacity; +} + +@keyframes particle-burst { + 0% { + opacity: 0; + transform: translate(-50%, -50%) rotate(var(--angle)) translateY(-8px) scale(0.9); + } + + 15% { + opacity: 0.9; + } + + 100% { + opacity: 0; + transform: translate(-50%, -50%) rotate(var(--angle)) translateY(calc(var(--distance) * -1)) + scale(0.5); + } +} + +@include motion.reduced-motion { + .particle { + display: none; + } +} diff --git a/src/app/common/celebrate/celebration-particles.component.ts b/src/app/common/celebrate/celebration-particles.component.ts new file mode 100644 index 0000000000..82cffadb26 --- /dev/null +++ b/src/app/common/celebrate/celebration-particles.component.ts @@ -0,0 +1,57 @@ +import {ChangeDetectionStrategy, Component, computed, input} from '@angular/core'; +import {prefersReducedMotion} from './reduced-motion'; + +interface Particle { + angle: number; + distance: number; + size: number; + colour: string; + delay: number; +} + +const PARTICLE_COLOURS = [ + 'var(--ot-color-success)', + 'var(--ot-color-primary)', + 'var(--ot-color-warning)', + 'var(--ot-color-info)', +]; + +/** + * A single light burst of small dots, played once. Each dot is one element moved + * with a transform, so it stays on the compositor. Nothing renders when the + * viewer prefers reduced motion. + */ +@Component({ + selector: 'f-celebration-particles', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './celebration-particles.component.html', + styleUrl: './celebration-particles.component.scss', + host: { + 'aria-hidden': 'true', + '[style.--burst-delay]': 'delay() + "ms"', + }, +}) +export class CelebrationParticlesComponent { + public readonly count = input(12); + /** How far, in pixels, the furthest dot travels from the centre. */ + public readonly spread = input(56); + public readonly delay = input(0); + + public readonly reducedMotion = prefersReducedMotion(); + + protected readonly particles = computed(() => { + const count = Math.max(0, Math.min(24, Math.round(this.count()))); + const spread = this.spread(); + + // Evenly spaced with a fixed jitter pattern, so the burst looks organic but + // renders the same way every time and never depends on Math.random. + return Array.from({length: count}, (_, index) => ({ + angle: (360 / Math.max(1, count)) * index + (index % 2 === 0 ? 6 : -4), + distance: spread * (index % 3 === 0 ? 1 : index % 3 === 1 ? 0.78 : 0.62), + size: index % 4 === 0 ? 6 : 4, + colour: PARTICLE_COLOURS[index % PARTICLE_COLOURS.length], + delay: (index % 3) * 20, + })); + }); +} diff --git a/src/app/common/celebrate/milestone-celebration.service.ts b/src/app/common/celebrate/milestone-celebration.service.ts new file mode 100644 index 0000000000..6c85e9c0ef --- /dev/null +++ b/src/app/common/celebrate/milestone-celebration.service.ts @@ -0,0 +1,243 @@ +import {Injectable, inject} from '@angular/core'; +import {MatDialog} from '@angular/material/dialog'; +import {firstValueFrom} from 'rxjs'; +import type {Project} from 'src/app/api/models/project'; +import type {TaskStatusEnum} from 'src/app/api/models/task-status'; +import {UserService} from 'src/app/api/services/user.service'; +import { + MilestoneChangeRow, + MilestoneDialogComponent, + MilestoneDialogData, + MilestoneDialogResult, + MilestoneTaskRow, +} from './milestone-dialog.component'; +import {prefersReducedMotion} from './reduced-motion'; +import {SeenStatusMap, TaskStatusSeenService, TaskStatusSnapshot} from './task-status-seen.service'; + +export interface MilestoneCheckOptions { + /** Local testing only. Shows the current completed tasks and leaves storage alone. */ + preview?: boolean; +} + +/** + * Decides whether a student opening their unit should see the signed off dialog, + * builds what it shows, and records the statuses so each change plays once. + */ +@Injectable({ + providedIn: 'root', +}) +export class MilestoneCelebrationService { + private readonly dialog = inject(MatDialog); + private readonly userService = inject(UserService); + private readonly seen = inject(TaskStatusSeenService); + + /** Resolves with the choice made in the dialog, or null when nothing was shown. */ + public async checkProject( + project: Project, + options: MilestoneCheckOptions = {}, + ): Promise { + const data = this.prepare(project, options); + if (!data) { + return null; + } + + const reduced = prefersReducedMotion(); + const dialogRef = this.dialog.open< + MilestoneDialogComponent, + MilestoneDialogData, + MilestoneDialogResult + >(MilestoneDialogComponent, { + data, + width: 'min(480px, calc(100vw - 32px))', + maxWidth: '480px', + maxHeight: 'calc(100dvh - 32px)', + panelClass: 'ot-milestone-dialog', + autoFocus: 'first-tabbable', + restoreFocus: true, + enterAnimationDuration: reduced ? '120ms' : '220ms', + exitAnimationDuration: reduced ? '100ms' : '150ms', + }); + + const result = await firstValueFrom(dialogRef.afterClosed()); + return result === 'view' ? 'view' : 'close'; + } + + /** + * Works out the dialog contents, and records the statuses for a real visit. Returns + * null for staff, a first visit, or a visit where nothing was signed off. + */ + public prepare( + project: Project, + options: MilestoneCheckOptions = {}, + ): MilestoneDialogData | null { + try { + const unit = project?.unit; + const definitions = unit?.taskDefinitions ?? []; + if (!project?.id || definitions.length === 0) { + return null; + } + + const snapshots: TaskStatusSnapshot[] = definitions.map((definition) => ({ + taskDefinitionId: definition.id, + status: project.findTaskForDefinition(definition.id)?.status ?? 'not_started', + })); + + if (options.preview) { + const completed = snapshots + .filter((snapshot) => snapshot.status === 'complete') + .map((snapshot) => snapshot.taskDefinitionId); + return completed.length > 0 ? this.buildData(project, completed, [], [], null) : null; + } + + if (!this.isOwnStudentProject(project)) { + return null; + } + + const userId = this.userService.currentUser.id; + const changes = this.seen.detectChanges(userId, project.id, snapshots); + // Recorded now, whatever happens next, so a change plays once and a first + // visit quietly starts from what is already there. + this.seen.record(userId, project.id, snapshots); + + if (changes.firstVisit || changes.completed.length === 0) { + return null; + } + + return this.buildData( + project, + changes.completed, + changes.readyToDiscuss, + changes.needsChanges, + changes.previous, + ); + } catch { + return null; + } + } + + private isOwnStudentProject(project: Project): boolean { + if (project.unit?.myRole !== 'Student') { + return false; + } + + const userId = this.userService?.currentUser?.id; + if (!userId) { + return false; + } + + const studentId = project.student?.id; + return studentId === undefined || studentId === null || studentId === userId; + } + + private buildData( + project: Project, + completedIds: number[], + discussIds: number[], + needsChangesIds: number[], + previous: SeenStatusMap | null, + ): MilestoneDialogData { + const unit = project.unit; + const definitions = unit.taskDefinitions; + const completedSet = new Set(completedIds); + + const completed: MilestoneTaskRow[] = definitions + .filter((definition) => completedSet.has(definition.id)) + .map((definition) => { + const task = project.findTaskForDefinition(definition.id); + const row: MilestoneTaskRow = { + taskDefinitionId: definition.id, + abbreviation: definition.abbreviation, + name: definition.name, + }; + + if (task?.hasGrade?.()) { + const grade = task.gradeDesc?.(); + if (grade) { + row.grade = grade; + } + } + + if (task?.hasQualityPoints?.()) { + row.stars = {earned: task.qualityPts, max: definition.maxQualityPts}; + } + + return row; + }); + + const alsoChanged: MilestoneChangeRow[] = []; + const addChange = (id: number, label: string) => { + const definition = definitions.find((candidate) => candidate.id === id); + if (definition && !completedSet.has(id)) { + alsoChanged.push({ + taskDefinitionId: id, + abbreviation: definition.abbreviation, + name: definition.name, + label, + }); + } + }; + + discussIds.forEach((id) => addChange(id, 'Ready to discuss with your tutor')); + needsChangesIds.forEach((id) => addChange(id, 'Needs changes')); + + const alreadyListed = new Set(alsoChanged.map((change) => change.taskDefinitionId)); + definitions.forEach((definition) => { + const task = project.findTaskForDefinition(definition.id); + if ((task?.numNewComments ?? 0) > 0 && !alreadyListed.has(definition.id)) { + addChange(definition.id, 'New feedback'); + } + }); + + const {from, to} = this.progress(project, completedSet, previous); + + return { + unitCode: unit.code ?? '', + completed, + alsoChanged, + progressFrom: from, + progressTo: to, + targetGradeLabel: this.targetGradeLabel(project), + }; + } + + /** + * Share of the target grade's tasks that are signed off, before and after. + * Before comes from the stored statuses. A preview has none, so it takes the + * listed tasks back out instead. + */ + private progress( + project: Project, + completedSet: Set, + previous: SeenStatusMap | null, + ): {from: number; to: number} { + const targets = project.unit.taskDefinitionsForGrade?.(project.targetGrade) ?? []; + if (targets.length === 0) { + return {from: 0, to: 0}; + } + + // Counted by task, the same way the progress dashboard says "4 of 16 tasks + // complete", so the two numbers on screen agree. + const total = targets.length; + const statusOf = (id: number): TaskStatusEnum | undefined => + project.findTaskForDefinition(id)?.status; + + const done = targets.filter((definition) => statusOf(definition.id) === 'complete').length; + + const before = previous + ? targets.filter((definition) => previous[String(definition.id)] === 'complete').length + : done - targets.filter((definition) => completedSet.has(definition.id)).length; + + return { + from: Math.round((Math.max(0, before) / total) * 100), + to: Math.round((done / total) * 100), + }; + } + + private targetGradeLabel(project: Project): string | undefined { + try { + return project.targetGradeWord || undefined; + } catch { + return undefined; + } + } +} diff --git a/src/app/common/celebrate/milestone-dialog.component.html b/src/app/common/celebrate/milestone-dialog.component.html new file mode 100644 index 0000000000..fc0bdf9154 --- /dev/null +++ b/src/app/common/celebrate/milestone-dialog.component.html @@ -0,0 +1,95 @@ +
+
+
+ + +
+

{{ heading }}

+ @if (data.unitCode) { +

{{ data.unitCode }}

+ } +
+ + +
+
+ {{ progressLabel }} + {{ progressTo }}% +
+
+
+
+ @if (progressTo > progressFrom) { +

Up from {{ progressFrom }}%

+ } +
+ +
    + @for (row of data.completed; track row.taskDefinitionId; let index = $index) { +
  • + + {{ row.abbreviation }} + {{ row.name }} + @if (row.grade) { + {{ row.grade }} + } + @if (row.stars) { + + + + } +
  • + } +
+ + @if (data.alsoChanged.length > 0) { +
+

Also changed

+
    + @for (change of data.alsoChanged; track change.taskDefinitionId + change.label) { +
  • + {{ change.abbreviation }} + {{ change.name }} + {{ change.label }} +
  • + } +
+
+ } +
+ + + + + +
diff --git a/src/app/common/celebrate/milestone-dialog.component.scss b/src/app/common/celebrate/milestone-dialog.component.scss new file mode 100644 index 0000000000..4c61ace299 --- /dev/null +++ b/src/app/common/celebrate/milestone-dialog.component.scss @@ -0,0 +1,269 @@ +@use './motion' as motion; + +// The dialog surface itself. Material starts it at scale(0.8), which reads as a +// pop. This one settles in from 0.95 on the shared ease-out curve, and stays +// centred because the transform origin is the middle of the surface. +.cdk-overlay-pane.ot-milestone-dialog { + .mat-mdc-dialog-surface { + transform: scale(0.95); + transition-timing-function: motion.$ease-out; + } + + .mdc-dialog--open .mat-mdc-dialog-surface { + transform: none; + } + + @include motion.reduced-motion { + .mat-mdc-dialog-surface { + transform: none; + } + } +} + +.ot-milestone { + @include motion.tokens; + + display: flex; + flex-direction: column; + color: var(--ot-color-text); +} + +.ot-milestone__hero { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 28px 24px 8px; + text-align: center; +} + +.ot-milestone__burst { + position: relative; + display: inline-flex; + margin-bottom: 8px; +} + +.ot-milestone .ot-milestone__heading { + margin: 0; + padding: 0; + color: var(--ot-color-text); + font-size: 22px; + font-weight: 600; + line-height: 1.25; + animation: celebrate-rise motion.$duration-enter motion.$ease-out both; + animation-delay: 80ms; + + &::before { + display: none; + } +} + +.ot-milestone__unit { + margin: 0; + color: var(--ot-color-text-muted); + font-size: 14px; + animation: celebrate-rise motion.$duration-enter motion.$ease-out both; + animation-delay: 120ms; +} + +.ot-milestone .ot-milestone__content { + display: flex; + flex-direction: column; + gap: 16px; + padding-top: 12px; + color: var(--ot-color-text); +} + +.ot-milestone__progress { + display: flex; + flex-direction: column; + gap: 6px; +} + +.ot-milestone__progress-text { + display: flex; + justify-content: space-between; + gap: 12px; + color: var(--ot-color-text-muted); + font-size: 13px; +} + +.ot-milestone__progress-value { + color: var(--ot-color-text); + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.ot-milestone__track { + position: relative; + height: 8px; + overflow: hidden; + border-radius: 999px; + background: var(--ot-color-divider); +} + +.ot-milestone__fill { + position: absolute; + inset: 0; + border-radius: inherit; + background: var(--ot-color-success); + transform-origin: left center; + transform: scaleX(var(--progress-to, 0)); + animation: ot-milestone-fill 600ms motion.$ease-in-out both; + animation-delay: 320ms; +} + +[dir='rtl'] .ot-milestone__fill { + transform-origin: right center; +} + +@keyframes ot-milestone-fill { + from { + transform: scaleX(var(--progress-from, 0)); + } + + to { + transform: scaleX(var(--progress-to, 0)); + } +} + +.ot-milestone__delta { + margin: 0; + color: var(--ot-color-text-muted); + font-size: 12px; +} + +.ot-milestone__rows, +.ot-milestone__also-list { + display: flex; + flex-direction: column; + margin: 0; + padding: 0; + list-style: none; +} + +.ot-milestone__rows { + gap: 2px; +} + +.ot-milestone__row { + display: flex; + align-items: center; + gap: 10px; + min-height: 40px; + padding: 6px 4px; + border-bottom: 1px solid var(--ot-color-divider); + animation: celebrate-rise motion.$duration-enter motion.$ease-out both; + animation-delay: var(--row-delay, 0ms); + + &:last-child { + border-bottom: 0; + } +} + +.ot-milestone__abbr { + flex: none; + min-width: 3.5em; + color: var(--ot-color-text); + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.ot-milestone__name { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ot-milestone__grade, +.ot-milestone__stars { + flex: none; + padding: 2px 8px; + border: 1px solid var(--ot-color-divider); + border-radius: 999px; + color: var(--ot-color-text-muted); + font-size: 12px; + font-variant-numeric: tabular-nums; +} + +.ot-milestone__also { + display: flex; + flex-direction: column; + gap: 6px; + padding-top: 4px; + animation: celebrate-fade motion.$duration-enter motion.$ease-out both; + animation-delay: 420ms; +} + +.ot-milestone .ot-milestone__also-heading { + margin: 0; + color: var(--ot-color-text-muted); + font-size: 12px; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; +} + +.ot-milestone__also-row { + display: flex; + align-items: baseline; + gap: 10px; + padding: 4px; + font-size: 13px; +} + +.ot-milestone__also-label { + flex: none; + color: var(--ot-color-text-muted); +} + +.ot-milestone .ot-milestone__actions { + gap: 8px; + padding: 8px 24px 20px; +} + +.ot-milestone .ot-milestone__button { + @include motion.press; +} + +@mixin ot-milestone-still { + .ot-milestone__heading, + .ot-milestone__unit, + .ot-milestone__row, + .ot-milestone__also { + animation: celebrate-fade 120ms linear both; + animation-delay: 0ms; + } + + .ot-milestone__fill { + animation: none; + } + + .ot-milestone__button, + .ot-milestone__button:active { + transition: none; + transform: none; + } +} + +.ot-milestone.reduced-motion { + @include ot-milestone-still; +} + +@include motion.reduced-motion { + .ot-milestone { + @include ot-milestone-still; + } +} + +@media (max-width: 479.98px) { + .ot-milestone__hero { + padding-inline: 16px; + } + + .ot-milestone .ot-milestone__actions { + padding-inline: 16px; + } +} diff --git a/src/app/common/celebrate/milestone-dialog.component.spec.ts b/src/app/common/celebrate/milestone-dialog.component.spec.ts new file mode 100644 index 0000000000..92807421a8 --- /dev/null +++ b/src/app/common/celebrate/milestone-dialog.component.spec.ts @@ -0,0 +1,213 @@ +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {TestBed} from '@angular/core/testing'; +import {MAT_DIALOG_DATA, MatDialogRef} from '@angular/material/dialog'; +import {Project} from 'src/app/api/models/project'; +import {UserService} from 'src/app/api/services/user.service'; +import {MilestoneCelebrationService} from './milestone-celebration.service'; +import {MilestoneDialogComponent, MilestoneDialogData} from './milestone-dialog.component'; +import {TaskStatusSeenService} from './task-status-seen.service'; + +describe('MilestoneDialogComponent', () => { + afterEach(() => TestBed.resetTestingModule()); + + const render = (data: MilestoneDialogData) => { + const close = vi.fn(); + TestBed.configureTestingModule({ + imports: [MilestoneDialogComponent], + providers: [ + {provide: MAT_DIALOG_DATA, useValue: data}, + {provide: MatDialogRef, useValue: {close}}, + ], + }); + const fixture = TestBed.createComponent(MilestoneDialogComponent); + fixture.detectChanges(); + return {fixture, close, element: fixture.nativeElement as HTMLElement}; + }; + + const data = (overrides: Partial = {}): MilestoneDialogData => ({ + unitCode: 'SIT374', + completed: [ + {taskDefinitionId: 1, abbreviation: '1.1P', name: 'Hello', grade: 'P'}, + {taskDefinitionId: 2, abbreviation: '2.1C', name: 'Sorting', stars: {earned: 3, max: 5}}, + {taskDefinitionId: 3, abbreviation: '3.1D', name: 'Graphs'}, + ], + alsoChanged: [], + progressFrom: 40, + progressTo: 55, + targetGradeLabel: 'Distinction', + ...overrides, + }); + + it('renders the count, the unit and each completed task in order', () => { + const {element} = render(data()); + + expect(element.querySelector('.ot-milestone__heading')?.textContent?.trim()).toBe( + '3 tasks signed off', + ); + expect(element.querySelector('.ot-milestone__unit')?.textContent?.trim()).toBe('SIT374'); + + const rows = Array.from(element.querySelectorAll('.ot-milestone__row')); + expect(rows.map((row) => row.querySelector('.ot-milestone__abbr')?.textContent)).toEqual([ + '1.1P', + '2.1C', + '3.1D', + ]); + expect(rows[0].querySelector('.ot-milestone__grade')?.textContent?.trim()).toBe('P'); + expect(rows[1].querySelector('.ot-milestone__stars')?.getAttribute('aria-label')).toBe( + '3 of 5 stars', + ); + expect(rows.every((row) => row.querySelector('f-animated-check'))).toBe(true); + }); + + it('uses the singular for one task', () => { + const {element} = render( + data({completed: [{taskDefinitionId: 1, abbreviation: '1.1P', name: 'Hello'}]}), + ); + + expect(element.querySelector('.ot-milestone__heading')?.textContent?.trim()).toBe( + '1 task signed off', + ); + }); + + it('staggers rows and caps the stagger so a long list still settles quickly', () => { + const {fixture} = render(data()); + const component = fixture.componentInstance; + + expect(component.rowDelay(1) - component.rowDelay(0)).toBe(50); + expect(component.rowDelay(20)).toBe(component.rowDelay(8)); + expect(component.rowDelay(20)).toBeLessThanOrEqual(600); + }); + + it('shows progress from the old share to the new one', () => { + const {element} = render(data()); + const track = element.querySelector('.ot-milestone__track'); + + expect(track?.getAttribute('aria-valuenow')).toBe('55'); + expect(track?.getAttribute('aria-valuetext')).toBe('55 percent, up from 40 percent'); + expect(element.querySelector('.ot-milestone__delta')?.textContent?.trim()).toBe('Up from 40%'); + }); + + it('lists other changes quietly below, and hides the section when there are none', () => { + const none = render(data()); + expect(none.element.querySelector('.ot-milestone__also')).toBeNull(); + TestBed.resetTestingModule(); + + const {element} = render( + data({ + alsoChanged: [ + {taskDefinitionId: 9, abbreviation: '4.1P', name: 'Stacks', label: 'Needs changes'}, + ], + }), + ); + expect(element.querySelector('.ot-milestone__also-label')?.textContent).toBe('Needs changes'); + }); + + it('closes with the chosen action', () => { + const {element, close} = render(data()); + const buttons = Array.from(element.querySelectorAll('button')); + + buttons.find((button) => button.textContent?.includes('View completed'))?.click(); + expect(close).toHaveBeenCalledWith('view'); + + buttons.find((button) => button.textContent?.trim() === 'Close')?.click(); + expect(close).toHaveBeenCalledWith('close'); + }); +}); + +describe('MilestoneCelebrationService.prepare', () => { + let stored: Map; + + afterEach(() => { + vi.unstubAllGlobals(); + TestBed.resetTestingModule(); + }); + + const definitions = [ + {id: 1, abbreviation: '1.1P', name: 'Hello', weighting: 1, maxQualityPts: 0, targetGrade: 0}, + {id: 2, abbreviation: '2.1P', name: 'Loops', weighting: 1, maxQualityPts: 0, targetGrade: 0}, + {id: 3, abbreviation: '3.1C', name: 'Sorting', weighting: 2, maxQualityPts: 0, targetGrade: 1}, + ]; + + const makeProject = (role: string, statuses: Record) => + ({ + id: 12, + targetGrade: 1, + targetGradeWord: 'Credit', + student: {id: 5}, + unit: { + code: 'SIT374', + myRole: role, + taskDefinitions: definitions, + taskDefinitionsForGrade: (grade: number) => + definitions.filter((d) => d.targetGrade <= grade), + }, + findTaskForDefinition: (id: number) => + statuses[id] ? {status: statuses[id], numNewComments: 0} : undefined, + }) as unknown as Project; + + const setup = () => { + stored = new Map(); + vi.stubGlobal('localStorage', { + getItem: (key: string) => stored.get(key) ?? null, + setItem: (key: string, value: string) => void stored.set(key, value), + }); + TestBed.configureTestingModule({ + providers: [{provide: UserService, useValue: {currentUser: {id: 5}}}], + }); + return { + service: TestBed.inject(MilestoneCelebrationService), + seen: TestBed.inject(TaskStatusSeenService), + }; + }; + + it('records silently on a first visit', () => { + const {service, seen} = setup(); + + expect(service.prepare(makeProject('Student', {1: 'complete'}))).toBeNull(); + expect(seen.read(5, 12)).toEqual({'1': 'complete', '2': 'not_started', '3': 'not_started'}); + }); + + it('builds the dialog for newly completed tasks with progress and other changes', () => { + const {service} = setup(); + service.prepare(makeProject('Student', {1: 'complete', 2: 'ready_for_feedback'})); + + const data = service.prepare( + makeProject('Student', {1: 'complete', 2: 'complete', 3: 'fix_and_resubmit'}), + ); + + expect(data?.completed.map((row) => row.abbreviation)).toEqual(['2.1P']); + expect(data?.alsoChanged).toEqual([ + {taskDefinitionId: 3, abbreviation: '3.1C', name: 'Sorting', label: 'Needs changes'}, + ]); + expect(data?.progressFrom).toBe(33); + expect(data?.progressTo).toBe(67); + expect(data?.unitCode).toBe('SIT374'); + + // Recorded when shown, so the same change does not play again. + expect( + service.prepare( + makeProject('Student', {1: 'complete', 2: 'complete', 3: 'fix_and_resubmit'}), + ), + ).toBeNull(); + }); + + it('never shows or records for staff', () => { + const {service, seen} = setup(); + + expect(service.prepare(makeProject('Tutor', {1: 'complete'}))).toBeNull(); + expect(seen.read(5, 12)).toBeNull(); + }); + + it('previews the current completed tasks without touching storage', () => { + const {service, seen} = setup(); + + const data = service.prepare(makeProject('Student', {1: 'complete', 3: 'complete'}), { + preview: true, + }); + + expect(data?.completed.length).toBe(2); + expect(data?.progressTo).toBe(67); + expect(data?.progressFrom).toBe(0); + expect(seen.read(5, 12)).toBeNull(); + }); +}); diff --git a/src/app/common/celebrate/milestone-dialog.component.ts b/src/app/common/celebrate/milestone-dialog.component.ts new file mode 100644 index 0000000000..9eec4dfe5a --- /dev/null +++ b/src/app/common/celebrate/milestone-dialog.component.ts @@ -0,0 +1,93 @@ +import {ChangeDetectionStrategy, Component, ViewEncapsulation, inject} from '@angular/core'; +import {MatButtonModule} from '@angular/material/button'; +import {MAT_DIALOG_DATA, MatDialogModule, MatDialogRef} from '@angular/material/dialog'; +import {AnimatedCheckComponent} from './animated-check.component'; +import {CelebrationParticlesComponent} from './celebration-particles.component'; +import {prefersReducedMotion} from './reduced-motion'; + +export interface MilestoneTaskRow { + taskDefinitionId: number; + abbreviation: string; + name: string; + grade?: string; + stars?: {earned: number; max: number}; +} + +export interface MilestoneChangeRow { + taskDefinitionId: number; + abbreviation: string; + name: string; + label: string; +} + +export interface MilestoneDialogData { + unitCode: string; + completed: MilestoneTaskRow[]; + alsoChanged: MilestoneChangeRow[]; + /** Whole percentages, 0 to 100. */ + progressFrom: number; + progressTo: number; + targetGradeLabel?: string; +} + +export type MilestoneDialogResult = 'view' | 'close'; + +/** Rows after this one share its delay, so a long list still settles quickly. */ +const MAX_STAGGERED_ROWS = 8; +const FIRST_ROW_DELAY_MS = 180; +const ROW_STAGGER_MS = 50; + +@Component({ + selector: 'f-milestone-dialog', + standalone: true, + imports: [ + MatDialogModule, + MatButtonModule, + AnimatedCheckComponent, + CelebrationParticlesComponent, + ], + changeDetection: ChangeDetectionStrategy.OnPush, + // The dialog surface lives outside this component, and its entrance is tuned + // here. Every selector is scoped under .ot-milestone so nothing leaks. + encapsulation: ViewEncapsulation.None, + templateUrl: './milestone-dialog.component.html', + styleUrl: './milestone-dialog.component.scss', +}) +export class MilestoneDialogComponent { + public readonly data = inject(MAT_DIALOG_DATA); + private readonly dialogRef = + inject>(MatDialogRef); + + public readonly reducedMotion = prefersReducedMotion(); + + public get heading(): string { + const count = this.data.completed.length; + return `${count} ${count === 1 ? 'task' : 'tasks'} signed off`; + } + + public get progressFrom(): number { + return this.clampPercent(this.data.progressFrom); + } + + public get progressTo(): number { + return this.clampPercent(this.data.progressTo); + } + + public get progressLabel(): string { + return this.data.targetGradeLabel + ? `Progress toward ${this.data.targetGradeLabel}` + : 'Progress this unit'; + } + + public rowDelay(index: number): number { + return FIRST_ROW_DELAY_MS + Math.min(index, MAX_STAGGERED_ROWS) * ROW_STAGGER_MS; + } + + public close(result: MilestoneDialogResult): void { + this.dialogRef.close(result); + } + + private clampPercent(value: number): number { + return Number.isFinite(value) ? Math.max(0, Math.min(100, Math.round(value))) : 0; + } +} diff --git a/src/app/common/celebrate/motion-demo/motion-demo.component.html b/src/app/common/celebrate/motion-demo/motion-demo.component.html new file mode 100644 index 0000000000..ed4b70e20a --- /dev/null +++ b/src/app/common/celebrate/motion-demo/motion-demo.component.html @@ -0,0 +1,196 @@ +
+
+

Motion

+

+ Every state on one page, so they can be judged next to each other. How often a person sees an + interaction decides how much motion it gets. A student submits a few times a week, so a + submission can be marked. A tutor changes a status a hundred times a day, so that one stays + silent. +

+ @if (reducedMotion) { +

+ info + Your system asks for reduced motion, so everything below plays as a short fade. +

+ } +
+ +
+

Submitted for feedback

+

+ Plays once when a student's own task reaches Ready for Feedback. In the app it now plays + inside the upload dialog the student started from, which then closes itself. The buttons here + show the same states as an overlay, because there is no dialog to sit in. +

+
    + @for (trigger of submissionTriggers; track trigger.label) { +
  • + + {{ trigger.note }} +
  • + } +
+
+ +
+

Signed off

+

+ Shown when a student opens their unit and finds work signed off since the last visit. Once per + change, never on a first visit. +

+
+ + + +
+
+ +
+

The mark on its own

+

+ The ring draws, then the tick draws inside it. It never starts from nothing, so it settles + into place instead of appearing. +

+
+
+ +
Success
+
+
+ +
Primary
+
+
+ +
Neutral
+
+
+ + + + +
With the burst
+
+
+
+ +

Candidates

+

+ Not shipped yet. Each one says who sees it and how often, because that is what decides whether + it earns its motion. +

+ +
+

Status changing in place

+

Student and tutor. Several times a session.

+

+ A short blur carries the old label into the new one, so it reads as one pill changing rather + than two swapping. The press scale confirms the click landed before the request comes back. +

+
+ + Click it +
+
+ +
+

Progress moving

+

Student. A few times a week.

+

+ The bar grows with a clip, not a width, so it stays off the main thread. It is slower than + normal UI on purpose: this is the one number a student came to the page to see. +

+
+
+ +
+
+ {{ progress() }}% + +
+
+
+ +
+

A row that just changed

+

Student. Rare, on returning to a list.

+

+ A wash passes across the row once. Nothing moves, so a list of twenty does not jump while it + is being read. +

+
+
+ check_circle + 5.3D Leadership progress report + Complete +
+ +
+
+ +
+

A comment arriving

+

Both. Tutors, many times an hour.

+

+ It rises from where the composer sits rather than fading in, so the eye follows it down the + thread. Kept to 180ms because a tutor sends these all day. +

+
+
+ @for (comment of comments(); track comment.id) { +

{{ comment.body }}

+ } +
+
+ + +
+
+
+ +
+

Files landing

+

Student. A few times a week.

+

+ Rows settle in at 40ms apart. The stagger is what makes four files read as four things rather + than one block appearing. +

+
+
    + @for (file of files(); track file; let index = $index) { +
  • + description + {{ file }} + +
  • + } +
+ +
+
+ +
+

What stays still

+

Tutor. Hundreds of times a day.

+

+ Marking a task, moving to the next student, and anything reached by a keyboard shortcut get no + animation at all. At that frequency motion reads as lag, and the tutor is already looking at + the next thing before it finishes. +

+
+
diff --git a/src/app/common/celebrate/motion-demo/motion-demo.component.scss b/src/app/common/celebrate/motion-demo/motion-demo.component.scss new file mode 100644 index 0000000000..4e6d81413b --- /dev/null +++ b/src/app/common/celebrate/motion-demo/motion-demo.component.scss @@ -0,0 +1,408 @@ +// Every colour resolves from an --ot-* token, so this page repaints with the theme. +@use '../motion' as motion; + +:host { + @include motion.tokens; + + display: block; + background: var(--ot-color-page); + color: var(--ot-color-text); +} + +.motion-demo { + box-sizing: border-box; + max-width: 880px; + margin-inline: auto; + padding: clamp(16px, 4vw, 32px); +} + +.intro { + margin-block-end: 32px; + + h1 { + margin: 0 0 8px; + font-size: 1.75rem; + font-weight: 650; + letter-spacing: -0.01em; + } + + p { + max-width: 62ch; + margin: 0; + color: var(--ot-color-text-muted); + line-height: 1.55; + } +} + +.warn { + display: flex; + align-items: center; + gap: 8px; + margin-block-start: 12px !important; + + mat-icon { + width: 18px; + height: 18px; + font-size: 18px; + } +} + +.group { + margin-block-end: 16px; + padding: 20px; + border: 1px solid var(--ot-color-border); + border-radius: var(--ot-radius-md, 10px); + background: var(--ot-color-surface); + + h2, + h3 { + margin: 0 0 6px; + font-size: 1.05rem; + font-weight: 620; + } +} + +.group__lede { + max-width: 62ch; + margin: 0 0 16px; + color: var(--ot-color-text-muted); + font-size: 0.9rem; + line-height: 1.5; +} + +.candidates-heading { + margin: 40px 0 6px; + font-size: 1.3rem; + font-weight: 650; +} + +.candidates-lede { + max-width: 62ch; + margin: 0 0 16px; + color: var(--ot-color-text-muted); + line-height: 1.55; +} + +.candidate__who { + margin: 0 0 10px; + color: var(--ot-color-text-muted); + font-size: 0.84rem; +} + +.candidate--none { + opacity: 0.8; +} + +.row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; +} + +.triggers { + display: grid; + gap: 12px 20px; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 22rem), 1fr)); + margin: 0; + padding: 0; + list-style: none; + + li { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; + } +} + +.triggers__note { + color: var(--ot-color-text-muted); + font-size: 0.82rem; + line-height: 1.45; +} + +.marks { + display: flex; + flex-wrap: wrap; + gap: 28px; + + figure { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + margin: 0; + } + + figcaption { + color: var(--ot-color-text-muted); + font-size: 0.8rem; + } +} + +.marks__burst { + position: relative; + display: inline-flex; +} + +.sample { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; +} + +.sample--stack { + flex-direction: column; + align-items: stretch; +} + +.sample__hint { + color: var(--ot-color-text-muted); + font-size: 0.8rem; +} + +// ---- Candidate 1: status pill ---------------------------------------------- +.status-pill { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 7px 14px 7px 10px; + border: 0; + border-radius: 999px; + background: var(--ot-status-not-started); + color: var(--ot-status-not-started-on); + font: inherit; + font-size: 0.875rem; + font-weight: 560; + cursor: pointer; + transition: + transform 140ms var(--ease-out), + background-color 200ms ease; + + &:active { + transform: scale(0.97); + } + + &:focus-visible { + outline: 2px solid var(--ot-color-focus); + outline-offset: 2px; + } + + mat-icon { + width: 18px; + height: 18px; + font-size: 18px; + } + + // The label and glyph blur through the swap. Two objects crossfading read as + // two objects; blurred, they read as one changing. + mat-icon, + span { + transition: + filter 110ms ease, + opacity 110ms ease; + } + + &.bridging { + mat-icon, + span { + opacity: 0.4; + filter: blur(3px); + } + } + + @each $tone in not-started, working-on-it, ready-for-feedback, discuss, complete { + &[data-tone='#{$tone}'] { + background: var(--ot-status-#{$tone}); + color: var(--ot-status-#{$tone}-on); + } + } +} + +// ---- Candidate 2: progress -------------------------------------------------- +.progress { + position: relative; + overflow: hidden; + height: 10px; + border-radius: 999px; + background: var(--ot-color-surface-raised); +} + +.progress__fill { + position: absolute; + inset: 0; + border-radius: inherit; + background: var(--ot-color-primary); + // Clipping keeps this on the compositor. Animating width would lay out every frame. + clip-path: inset(0 calc(100% - var(--progress, 0%)) 0 0); + transition: clip-path 520ms var(--ease-in-out); +} + +.progress__value { + min-width: 3.5rem; + color: var(--ot-color-text-muted); + font-size: 0.875rem; + font-variant-numeric: tabular-nums; +} + +// ---- Candidate 3: the wash -------------------------------------------------- +.task-row { + position: relative; + display: flex; + align-items: center; + gap: 10px; + overflow: hidden; + padding: 12px 14px; + border: 1px solid var(--ot-color-border); + border-radius: var(--ot-radius-md, 10px); + background: var(--ot-color-surface-raised); + + mat-icon { + width: 20px; + height: 20px; + color: var(--ot-status-complete); + font-size: 20px; + } + + // A band of light crosses once. It sits above the row and below nothing else. + &::after { + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 55%; + background: linear-gradient( + 90deg, + transparent, + color-mix(in srgb, var(--ot-color-primary) 16%, transparent), + transparent + ); + content: ''; + opacity: 0; + pointer-events: none; + transform: translateX(-100%); + } + + &.washed::after { + animation: row-wash 900ms var(--ease-in-out) both; + } +} + +.task-row__name { + flex: 1 1 auto; + min-width: 0; + font-size: 0.9rem; +} + +.task-row__status { + color: var(--ot-color-text-muted); + font-size: 0.8rem; +} + +@keyframes row-wash { + from { + opacity: 1; + transform: translateX(-100%); + } + + to { + opacity: 1; + transform: translateX(220%); + } +} + +// ---- Candidate 4: comments -------------------------------------------------- +.thread { + display: flex; + flex-direction: column; + gap: 8px; + min-height: 60px; + padding: 12px; + border: 1px solid var(--ot-color-border); + border-radius: var(--ot-radius-md, 10px); + background: var(--ot-color-surface-raised); +} + +.bubble { + max-width: 34ch; + margin: 0; + padding: 9px 13px; + border-radius: 14px 14px 14px 4px; + background: var(--ot-color-surface); + font-size: 0.875rem; + line-height: 1.45; + // Rises from the composer, so the eye follows it into place. + animation: bubble-in 180ms var(--ease-out) both; +} + +.bubble--mine { + align-self: flex-end; + border-radius: 14px 14px 4px; + background: var(--ot-color-primary); + color: var(--ot-color-on-primary); +} + +@keyframes bubble-in { + from { + opacity: 0; + transform: translateY(10px); + } + + to { + opacity: 1; + transform: none; + } +} + +// ---- Candidate 5: files ----------------------------------------------------- +.files { + display: flex; + flex-direction: column; + gap: 6px; + min-height: 24px; + margin: 0; + padding: 0; + list-style: none; +} + +.files__row { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 12px; + border: 1px solid var(--ot-color-border); + border-radius: var(--ot-radius-md, 10px); + background: var(--ot-color-surface-raised); + font-size: 0.875rem; + animation: celebrate-rise 200ms var(--ease-out) both; + animation-delay: var(--row-delay, 0ms); + + mat-icon { + width: 18px; + height: 18px; + color: var(--ot-color-text-muted); + font-size: 18px; + } + + span { + flex: 1 1 auto; + min-width: 0; + } +} + +@include motion.reduced-motion { + .progress__fill, + .status-pill, + .status-pill mat-icon, + .status-pill span { + transition-duration: 1ms; + } + + .task-row.washed::after, + .bubble, + .files__row { + animation: celebrate-fade 120ms linear both; + } +} diff --git a/src/app/common/celebrate/motion-demo/motion-demo.component.ts b/src/app/common/celebrate/motion-demo/motion-demo.component.ts new file mode 100644 index 0000000000..2577801ab8 --- /dev/null +++ b/src/app/common/celebrate/motion-demo/motion-demo.component.ts @@ -0,0 +1,251 @@ +import {ChangeDetectionStrategy, Component, inject, signal} from '@angular/core'; +import {MatButtonModule} from '@angular/material/button'; +import {MatDialog} from '@angular/material/dialog'; +import {MatIconModule} from '@angular/material/icon'; +import {AnimatedCheckComponent} from '../animated-check.component'; +import {CelebrationParticlesComponent} from '../celebration-particles.component'; +import {MilestoneDialogComponent, MilestoneDialogData} from '../milestone-dialog.component'; +import {prefersReducedMotion} from '../reduced-motion'; +import {SubmissionCelebrationService} from '../submission-celebration.service'; +import {SubmissionTiming, buildSubmissionCelebration} from '../submission-timing'; + +interface CelebrationTrigger { + label: string; + timing: SubmissionTiming; + resubmission: boolean; + note: string; +} + +interface StatusSample { + key: string; + label: string; + icon: string; + tone: string; +} + +const STATUS_CYCLE: StatusSample[] = [ + {key: 'not_started', label: 'Not started', icon: 'radio_button_unchecked', tone: 'not-started'}, + {key: 'working_on_it', label: 'Working on it', icon: 'edit', tone: 'working-on-it'}, + { + key: 'ready_for_feedback', + label: 'Ready for feedback', + icon: 'send', + tone: 'ready-for-feedback', + }, + {key: 'discuss', label: 'Ready to discuss', icon: 'forum', tone: 'discuss'}, + {key: 'complete', label: 'Complete', icon: 'check', tone: 'complete'}, +]; + +/** + * A place to look at every motion decision on one page. Nothing here ships to a + * student: it exists so the states can be compared next to each other rather + * than found one at a time by doing real work in the app. + * + * Each candidate carries the frequency that justifies it. An interaction a tutor + * repeats a hundred times a day gets less motion, not more. + */ +@Component({ + selector: 'app-motion-demo', + standalone: true, + imports: [MatButtonModule, MatIconModule, AnimatedCheckComponent, CelebrationParticlesComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './motion-demo.component.html', + styleUrl: './motion-demo.component.scss', +}) +export class MotionDemoComponent { + private readonly celebrations = inject(SubmissionCelebrationService); + private readonly dialog = inject(MatDialog); + + public readonly reducedMotion = prefersReducedMotion(); + + public readonly submissionTriggers: CelebrationTrigger[] = [ + { + label: 'On time', + timing: 'on_time', + resubmission: false, + note: 'Rare and worth marking. Ring, tick, then one light burst.', + }, + { + label: 'On time, resubmitted', + timing: 'on_time', + resubmission: true, + note: 'Same warmth, different words. Getting back on track counts.', + }, + { + label: 'After the target date', + timing: 'after_target', + resubmission: false, + note: 'Still positive, no burst. The moment is not a win, it is progress.', + }, + { + label: 'After the target date, resubmitted', + timing: 'after_target', + resubmission: true, + note: 'Quieter again. Nothing celebratory, just a clear confirmation.', + }, + { + label: 'After the due date', + timing: 'after_due', + resubmission: false, + note: 'Neutral tone. Confirms the upload and points at the tutor.', + }, + { + label: 'After the due date, resubmitted', + timing: 'after_due', + resubmission: true, + note: 'The one state where a celebration would read as tone deaf.', + }, + ]; + + // ---- Candidate 1: status pill change ------------------------------------- + public readonly statusIndex = signal(0); + public readonly statusBridging = signal(false); + + public get status(): StatusSample { + return STATUS_CYCLE[this.statusIndex() % STATUS_CYCLE.length]; + } + + public advanceStatus(): void { + // A blur through the swap bridges the two labels, so it reads as one pill + // changing rather than two pills trading places. + this.statusBridging.set(true); + setTimeout(() => { + this.statusIndex.update((index) => (index + 1) % STATUS_CYCLE.length); + this.statusBridging.set(false); + }, 110); + } + + // ---- Candidate 2: progress bar ------------------------------------------ + public readonly progress = signal(25); + + public bumpProgress(): void { + this.progress.update((value) => (value >= 100 ? 0 : Math.min(100, value + 25))); + } + + // ---- Candidate 3: a row that just changed -------------------------------- + public readonly washing = signal(false); + + public washRow(): void { + this.washing.set(false); + // Restart the wash even when it is already running. + requestAnimationFrame(() => this.washing.set(true)); + setTimeout(() => this.washing.set(false), 1200); + } + + // ---- Candidate 4: comment entry ----------------------------------------- + public readonly comments = signal<{id: number; body: string; mine: boolean}[]>([ + {id: 1, body: 'Have a look at the second requirement before you resubmit.', mine: false}, + ]); + private nextCommentId = 2; + + public sendComment(): void { + const id = this.nextCommentId++; + this.comments.update((rows) => [ + ...rows, + {id, body: 'Fixed it, thanks. Uploading the new version now.', mine: id % 2 === 0}, + ]); + } + + public clearComments(): void { + this.comments.set([]); + this.nextCommentId = 2; + } + + // ---- Candidate 5: files landing ----------------------------------------- + public readonly files = signal([]); + + public dropFiles(): void { + this.files.set([]); + requestAnimationFrame(() => + this.files.set(['report.pdf', 'analysis.ipynb', 'screenshot.png', 'notes.md']), + ); + } + + // ---- Triggers ------------------------------------------------------------ + public playSubmission(trigger: CelebrationTrigger): void { + this.celebrations.show( + buildSubmissionCelebration({ + timing: trigger.timing, + resubmission: trigger.resubmission, + abbreviation: 'Task 3.1P', + name: 'Update your company mentor', + }), + ); + } + + public playMilestone(size: 'one' | 'several' | 'mixed'): void { + this.dialog.open(MilestoneDialogComponent, { + data: this.milestoneData(size), + width: 'min(480px, calc(100vw - 32px))', + maxWidth: '480px', + maxHeight: 'calc(100dvh - 32px)', + panelClass: 'ot-milestone-dialog', + autoFocus: 'first-tabbable', + restoreFocus: true, + enterAnimationDuration: this.reducedMotion ? '120ms' : '220ms', + exitAnimationDuration: this.reducedMotion ? '100ms' : '150ms', + }); + } + + private milestoneData(size: 'one' | 'several' | 'mixed'): MilestoneDialogData { + const base: MilestoneDialogData = { + unitCode: 'SIT374', + completed: [{taskDefinitionId: 1, abbreviation: '3.1P', name: 'Update your company mentor'}], + alsoChanged: [], + progressFrom: 18, + progressTo: 25, + targetGradeLabel: 'High Distinction', + }; + + if (size === 'one') { + return base; + } + + const completed = [ + {taskDefinitionId: 1, abbreviation: '3.1P', name: 'Update your company mentor'}, + { + taskDefinitionId: 2, + abbreviation: '5.2C', + name: 'Responsible team member progress report', + grade: 'Credit', + }, + { + taskDefinitionId: 3, + abbreviation: '5.3D', + name: 'Leadership progress report', + stars: {earned: 4, max: 5}, + }, + ]; + + if (size === 'several') { + return {...base, completed, progressFrom: 18, progressTo: 44}; + } + + return { + ...base, + completed, + alsoChanged: [ + { + taskDefinitionId: 4, + abbreviation: '5.4HD', + name: 'Panel presentation', + label: 'Ready to discuss with your tutor', + }, + { + taskDefinitionId: 5, + abbreviation: '4.1P', + name: 'Update your company mentor (II)', + label: 'Needs changes', + }, + { + taskDefinitionId: 6, + abbreviation: '2.3D', + name: 'Lead contributions contract', + label: 'New feedback', + }, + ], + progressFrom: 18, + progressTo: 44, + }; + } +} diff --git a/src/app/common/celebrate/motion-demo/submit-flow-lab.component.html b/src/app/common/celebrate/motion-demo/submit-flow-lab.component.html new file mode 100644 index 0000000000..aaaff3140c --- /dev/null +++ b/src/app/common/celebrate/motion-demo/submit-flow-lab.component.html @@ -0,0 +1,35 @@ +
+ + +

{{ title }}

+

{{ detail }}

+ +
+ +
+ +
+ @if (done) { + + } @else if (running) { + + } @else { + + } +
+
diff --git a/src/app/common/celebrate/motion-demo/submit-flow-lab.component.scss b/src/app/common/celebrate/motion-demo/submit-flow-lab.component.scss new file mode 100644 index 0000000000..4cf91fd38e --- /dev/null +++ b/src/app/common/celebrate/motion-demo/submit-flow-lab.component.scss @@ -0,0 +1,527 @@ +// Six ways to get from "uploading" to "submitted". The markup is the same for all +// of them; everything below the shared block is one variant's stylesheet, so the +// one that wins can go back into the real dialog as CSS alone. + +$ease-out: cubic-bezier(0.23, 1, 0.32, 1); +$ease-in-out: cubic-bezier(0.77, 0, 0.175, 1); + +:host { + display: block; +} + +// ---- Shared: the parts, with no opinion about how they change ---- +.flow { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + min-height: 320px; + padding: 32px 16px; + border: 1px solid var(--ot-color-border); + border-radius: var(--ot-radius-md, 10px); + background: var(--ot-color-surface); + text-align: center; +} + +.flow__mark { + display: inline-flex; + align-items: center; + justify-content: center; + // Reserves the confirmation's height from the start, so nothing below it moves + // when the circle grows. Every variant inherits this. + height: 104px; +} + +.flow__ring { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 64px; + height: 64px; + border-radius: 999px; + background: color-mix(in srgb, var(--ot-color-primary) 14%, transparent); + color: var(--ot-color-primary); +} + +.flow__glyph { + width: 28px; + height: 28px; + font-size: 28px; +} + +.flow__title { + margin: 14px 0 0; + color: var(--ot-color-text); + font-size: 1.05rem; + font-weight: 620; + line-height: 1.3; +} + +.flow__detail { + max-width: 32ch; + margin: 0; + color: var(--ot-color-text-muted); + font-size: 0.875rem; + line-height: 1.45; + overflow-wrap: anywhere; +} + +.flow__track { + position: relative; + overflow: hidden; + width: min(300px, 100%); + height: 8px; + margin-block-start: 14px; + border-radius: 999px; + background: var(--ot-color-surface-raised); +} + +.flow__fill { + position: absolute; + inset: 0; + border-radius: inherit; + background: var(--ot-color-primary); + clip-path: inset(0 calc(100% - var(--progress, 0%)) 0 0); + transition: + clip-path 200ms linear, + background-color 300ms ease; +} + +.flow__actions { + margin-block-start: 16px; + + button { + transition: transform 140ms $ease-out; + + &:active { + transform: scale(0.97); + } + } +} + +// Every variant agrees on these two: the circle turns green when the bytes land, +// and the words get bigger once it is confirmed. +.flow--landed .flow__ring { + background: color-mix(in srgb, var(--ot-color-success) 16%, transparent); + color: var(--ot-color-success); +} + +.flow--landed .flow__fill { + background: var(--ot-color-success); +} + +.flow--done { + .flow__ring { + width: 104px; + height: 104px; + background: transparent; + } + + .flow__title { + max-width: 18ch; + margin-block-start: 18px; + font-size: clamp(1.35rem, 3vw, 1.75rem); + font-weight: 680; + line-height: 1.2; + letter-spacing: -0.02em; + } + + .flow__track { + height: 0; + margin-block-start: 0; + opacity: 0; + } +} + +// ============================================================================ +// 1. Blur. Everything softens away, changes, and sharpens back. +// ============================================================================ +:host([data-variant='blur']) { + .flow__ring, + .flow__title, + .flow__track { + transition: + width 300ms $ease-in-out, + height 300ms $ease-in-out, + margin 300ms $ease-out, + font-size 300ms $ease-out, + background-color 300ms ease, + color 300ms ease, + opacity 200ms ease, + filter 200ms ease; + } + + .flow__glyph, + .flow__detail, + .flow__actions { + transition: + opacity 200ms ease, + filter 200ms ease; + } + + .flow--swapping { + .flow__title, + .flow__detail, + .flow__glyph, + .flow__actions { + opacity: 0; + filter: blur(4px); + } + + .flow__track { + opacity: 0; + } + } +} + +// ============================================================================ +// 2. Lift. The old line leaves upward and the new one arrives from below, the +// way a departure board changes. Direction carries the sense of progress. +// ============================================================================ +:host([data-variant='lift']) { + .flow__ring { + transition: + width 320ms $ease-in-out, + height 320ms $ease-in-out, + background-color 300ms ease, + color 300ms ease; + } + + .flow__title, + .flow__detail, + .flow__actions { + transition: + transform 200ms $ease-out, + opacity 180ms ease; + } + + .flow__track { + transition: + height 240ms $ease-out, + margin 240ms $ease-out, + opacity 180ms ease; + } + + .flow--swapping { + .flow__title, + .flow__detail, + .flow__actions { + opacity: 0; + transform: translateY(-10px); + } + + .flow__track { + opacity: 0; + } + } + + .flow--done { + .flow__title { + animation: lab-lift-in 320ms $ease-out both; + } + + .flow__detail { + animation: lab-lift-in 320ms $ease-out both; + animation-delay: 70ms; + } + + .flow__actions { + animation: lab-lift-in 320ms $ease-out both; + animation-delay: 160ms; + } + } +} + +@keyframes lab-lift-in { + from { + opacity: 0; + transform: translateY(14px); + } + + to { + opacity: 1; + transform: none; + } +} + +// ============================================================================ +// 3. Circle led. The mark grows first and everything else waits for it. The +// circle is the subject; the words are a caption that catches up. +// ============================================================================ +:host([data-variant='circle']) { + .flow__ring { + transition: + width 420ms $ease-in-out, + height 420ms $ease-in-out, + background-color 300ms ease, + color 300ms ease; + } + + .flow__title, + .flow__detail, + .flow__actions, + .flow__track { + transition: + opacity 160ms ease, + height 300ms $ease-out, + margin 300ms $ease-out; + } + + // The circle starts growing while the words are still leaving. + .flow--swapping { + .flow__ring { + width: 104px; + height: 104px; + background: transparent; + } + + .flow__title, + .flow__detail, + .flow__actions, + .flow__track { + opacity: 0; + } + } + + .flow--done { + .flow__title { + animation: lab-fade-up 300ms $ease-out both; + animation-delay: 240ms; + } + + .flow__detail { + animation: lab-fade-up 300ms $ease-out both; + animation-delay: 320ms; + } + + .flow__actions { + animation: lab-fade-up 300ms $ease-out both; + animation-delay: 420ms; + } + } +} + +@keyframes lab-fade-up { + from { + opacity: 0; + transform: translateY(8px); + } + + to { + opacity: 1; + transform: none; + } +} + +// ============================================================================ +// 4. Wipe. A clip runs down the text block and back, so the words are uncovered +// rather than faded. Nothing moves, which keeps a long headline steady. +// ============================================================================ +:host([data-variant='wipe']) { + .flow__ring { + transition: + width 320ms $ease-in-out, + height 320ms $ease-in-out, + background-color 300ms ease, + color 300ms ease; + } + + .flow__title, + .flow__detail { + transition: clip-path 240ms $ease-in-out; + clip-path: inset(0 0 0 0); + } + + .flow__track, + .flow__actions { + transition: opacity 200ms ease; + } + + .flow--swapping { + .flow__title, + .flow__detail { + clip-path: inset(0 0 100% 0); + } + + .flow__track, + .flow__actions { + opacity: 0; + } + } + + .flow--done { + .flow__title { + animation: lab-wipe-in 320ms $ease-in-out both; + } + + .flow__detail { + animation: lab-wipe-in 320ms $ease-in-out both; + animation-delay: 90ms; + } + + .flow__actions { + animation: lab-fade-up 280ms $ease-out both; + animation-delay: 240ms; + } + } +} + +@keyframes lab-wipe-in { + from { + clip-path: inset(0 0 100% 0); + } + + to { + clip-path: inset(0 0 0 0); + } +} + +// ============================================================================ +// 5. Collapse. The bar draws in from both ends into the middle, the circle takes +// the hit, and the mark draws out of it. The one that treats the bar and the +// mark as the same object rather than two. +// ============================================================================ +:host([data-variant='collapse']) { + .flow__ring { + transition: + width 380ms $ease-in-out, + height 380ms $ease-in-out, + background-color 300ms ease, + color 300ms ease; + } + + .flow__track { + transition: + width 300ms $ease-in-out, + height 200ms $ease-out 260ms, + margin 200ms $ease-out 260ms, + opacity 160ms ease 280ms; + } + + .flow__title, + .flow__detail, + .flow__actions { + transition: + opacity 180ms ease, + transform 200ms $ease-out; + } + + .flow--swapping { + // The bar narrows to nothing, then the circle answers it. + .flow__track { + width: 0; + } + + .flow__ring { + animation: lab-absorb 320ms $ease-out 240ms both; + } + + .flow__title, + .flow__detail, + .flow__actions { + opacity: 0; + transform: translateY(6px); + } + } + + .flow--done { + .flow__track { + width: 0; + } + + .flow__title { + animation: lab-fade-up 300ms $ease-out both; + animation-delay: 160ms; + } + + .flow__detail { + animation: lab-fade-up 300ms $ease-out both; + animation-delay: 240ms; + } + + .flow__actions { + animation: lab-fade-up 300ms $ease-out both; + animation-delay: 340ms; + } + } +} + +@keyframes lab-absorb { + 0% { + transform: scale(1); + } + + 45% { + transform: scale(1.07); + } + + 100% { + transform: scale(1); + } +} + +// ============================================================================ +// 6. Settle. The whole panel dips a little and comes back. One gesture for the +// whole thing, instead of each part having its own opinion. +// ============================================================================ +:host([data-variant='settle']) { + .flow { + transition: + transform 200ms $ease-out, + opacity 160ms ease; + } + + .flow__ring { + transition: + width 340ms $ease-in-out, + height 340ms $ease-in-out, + background-color 300ms ease, + color 300ms ease; + } + + .flow__track { + transition: + height 240ms $ease-out, + margin 240ms $ease-out, + opacity 180ms ease; + } + + .flow--swapping { + transform: scale(0.96); + opacity: 0.4; + + .flow__track { + opacity: 0; + } + } + + .flow--done { + animation: lab-settle-in 360ms $ease-out both; + } +} + +@keyframes lab-settle-in { + from { + opacity: 0.6; + transform: scale(0.97); + } + + to { + opacity: 1; + transform: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .flow, + .flow *, + .flow--done, + .flow--done * { + transition: none !important; + animation: none !important; + opacity: 1 !important; + clip-path: none !important; + transform: none !important; + } +} diff --git a/src/app/common/celebrate/motion-demo/submit-flow-lab.component.ts b/src/app/common/celebrate/motion-demo/submit-flow-lab.component.ts new file mode 100644 index 0000000000..b6cd0547d2 --- /dev/null +++ b/src/app/common/celebrate/motion-demo/submit-flow-lab.component.ts @@ -0,0 +1,123 @@ +import {ChangeDetectionStrategy, Component, OnDestroy, input, signal} from '@angular/core'; +import {MatButtonModule} from '@angular/material/button'; +import {MatIconModule} from '@angular/material/icon'; +import {AnimatedCheckComponent} from '../animated-check.component'; +import {CelebrationParticlesComponent} from '../celebration-particles.component'; + +/** How the panel gets from the uploading state to the confirmation. */ +export type SubmitFlowVariant = 'blur' | 'lift' | 'circle' | 'wipe' | 'collapse' | 'settle'; + +export type SubmitFlowPhase = 'idle' | 'uploading' | 'landed' | 'swapping' | 'done'; + +/** Milliseconds each variant spends between the two sets of words. */ +const SWAP_MS: Record = { + blur: 220, + lift: 200, + circle: 420, + wipe: 260, + collapse: 480, + settle: 180, +}; + +const UPLOAD_MS = 1300; +const LANDED_MS = 600; + +/** + * One submission panel, played on demand, with the handover done a different way + * per variant. The markup is identical across all of them: only the stylesheet + * differs, so whichever wins can go back into the real dialog as CSS alone. + */ +@Component({ + selector: 'f-submit-flow-lab', + standalone: true, + imports: [MatButtonModule, MatIconModule, AnimatedCheckComponent, CelebrationParticlesComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './submit-flow-lab.component.html', + styleUrl: './submit-flow-lab.component.scss', + host: { + '[attr.data-variant]': 'variant()', + }, +}) +export class SubmitFlowLabComponent implements OnDestroy { + public readonly variant = input.required(); + + public readonly phase = signal('idle'); + public readonly progress = signal(0); + + private timers: ReturnType[] = []; + private frame: number | null = null; + + public get done(): boolean { + return this.phase() === 'done'; + } + + public get landed(): boolean { + return this.phase() === 'landed' || this.phase() === 'swapping' || this.done; + } + + public get swapping(): boolean { + return this.phase() === 'swapping'; + } + + public get running(): boolean { + return this.phase() !== 'idle'; + } + + public get title(): string { + if (this.done) { + return 'Submitted on time. Ready for feedback'; + } + return this.landed ? 'Uploaded' : 'Uploading your work'; + } + + public get detail(): string { + return this.done ? '3.1P Update your company mentor' : 'report.pdf'; + } + + public play(): void { + this.reset(); + this.phase.set('uploading'); + + // A real upload arrives in bursts. A smooth ramp is enough to judge the + // handover, which is the only thing this page is for. + const started = performance.now(); + const step = () => { + const elapsed = performance.now() - started; + this.progress.set(Math.min(100, Math.round((elapsed / UPLOAD_MS) * 100))); + if (elapsed < UPLOAD_MS && this.phase() === 'uploading') { + this.frame = requestAnimationFrame(step); + } + }; + this.frame = requestAnimationFrame(step); + + this.after(UPLOAD_MS, () => { + this.progress.set(100); + this.phase.set('landed'); + }); + this.after(UPLOAD_MS + LANDED_MS, () => this.phase.set('swapping')); + this.after(UPLOAD_MS + LANDED_MS + SWAP_MS[this.variant()], () => this.phase.set('done')); + } + + public reset(): void { + this.clear(); + this.phase.set('idle'); + this.progress.set(0); + } + + public ngOnDestroy(): void { + this.clear(); + } + + private after(delay: number, run: () => void): void { + this.timers.push(setTimeout(run, delay)); + } + + private clear(): void { + this.timers.forEach(clearTimeout); + this.timers = []; + if (this.frame !== null) { + cancelAnimationFrame(this.frame); + this.frame = null; + } + } +} diff --git a/src/app/common/celebrate/motion-demo/submit-motion.component.html b/src/app/common/celebrate/motion-demo/submit-motion.component.html new file mode 100644 index 0000000000..3835c2bd67 --- /dev/null +++ b/src/app/common/celebrate/motion-demo/submit-motion.component.html @@ -0,0 +1,60 @@ +
+
+

Submitting

+

+ The same panel six ways. Each one uploads, lands, and becomes the confirmation. What changes + between them is only how the handover in the middle is done, so whichever reads best can go + back into the dialog as a stylesheet. +

+
+ + +
+ @if (reducedMotion) { +

+ + Your system asks for reduced motion, so every panel below jumps straight to its end state. + Turn that off to compare them. +

+ } +
+ +
+ @for (card of variants; track card.variant) { +
+

{{ card.name }}

+

{{ card.idea }}

+ +

{{ card.cost }}

+
+ } +
+ +
+

Handing in a portfolio

+

+ The end of a whole unit, which a student reaches once per unit per trimester. It is the rarest + thing here, so it is the slowest: the mark draws at 2.6 times the usual pace, the words arrive + after it lands, and the facts settle one at a time. It has no timer, because this is the one + moment worth staying on. +

+
+ + +
+
+ +
+

What is the same in all six

+

+ The circle turns green the moment the bytes land, before the confirmation is worked out, so + the wait for the server is not dead time. The mark's space is reserved from the start, so + nothing below it moves when the circle grows. And the bar never disappears: it folds to + nothing, because something vanishing is the thing that reads as a new screen. +

+
+
diff --git a/src/app/common/celebrate/motion-demo/submit-motion.component.scss b/src/app/common/celebrate/motion-demo/submit-motion.component.scss new file mode 100644 index 0000000000..d3f23f97ad --- /dev/null +++ b/src/app/common/celebrate/motion-demo/submit-motion.component.scss @@ -0,0 +1,113 @@ +:host { + display: block; + background: var(--ot-color-page); + color: var(--ot-color-text); +} + +.lab { + box-sizing: border-box; + max-width: 1200px; + margin-inline: auto; + padding: clamp(16px, 4vw, 32px); +} + +.lab__intro { + margin-block-end: 28px; + + h1 { + margin: 0 0 8px; + font-size: 1.75rem; + font-weight: 650; + letter-spacing: -0.01em; + } + + p { + max-width: 66ch; + margin: 0; + color: var(--ot-color-text-muted); + line-height: 1.55; + } +} + +.lab__controls { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-block-start: 16px; + + button { + transition: transform 140ms cubic-bezier(0.23, 1, 0.32, 1); + + &:active { + transform: scale(0.97); + } + } +} + +.lab__warn { + display: flex; + align-items: center; + gap: 8px; + margin-block-start: 14px !important; + + mat-icon { + width: 18px; + height: 18px; + font-size: 18px; + } +} + +.lab__grid { + display: grid; + gap: 20px; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 22rem), 1fr)); +} + +.lab__card { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; + + h2 { + margin: 0; + font-size: 1rem; + font-weight: 620; + } +} + +.lab__idea { + margin: 0 0 4px; + color: var(--ot-color-text); + font-size: 0.875rem; + line-height: 1.5; +} + +.lab__cost { + margin: 0; + color: var(--ot-color-text-muted); + font-size: 0.82rem; + line-height: 1.5; +} + +.lab__note { + margin-block-start: 32px; + padding: 20px; + border: 1px solid var(--ot-color-border); + border-radius: var(--ot-radius-md, 10px); + background: var(--ot-color-surface); + + h2 { + margin: 0 0 8px; + font-size: 1rem; + font-weight: 620; + } + + p { + max-width: 66ch; + margin: 0; + color: var(--ot-color-text-muted); + font-size: 0.9rem; + line-height: 1.55; + } +} diff --git a/src/app/common/celebrate/motion-demo/submit-motion.component.ts b/src/app/common/celebrate/motion-demo/submit-motion.component.ts new file mode 100644 index 0000000000..24838c77bc --- /dev/null +++ b/src/app/common/celebrate/motion-demo/submit-motion.component.ts @@ -0,0 +1,104 @@ +import { + ChangeDetectionStrategy, + Component, + type QueryList, + ViewChildren, + inject, +} from '@angular/core'; +import {MatButtonModule} from '@angular/material/button'; +import {MatIconModule} from '@angular/material/icon'; +import {PortfolioCelebrationService} from '../portfolio-celebration.service'; +import {prefersReducedMotion} from '../reduced-motion'; +import {SubmitFlowLabComponent, type SubmitFlowVariant} from './submit-flow-lab.component'; + +interface VariantCard { + variant: SubmitFlowVariant; + name: string; + idea: string; + cost: string; +} + +/** + * Six takes on the same handover, side by side. The panel markup is shared, so + * the difference between any two of these is only their stylesheet. + */ +@Component({ + selector: 'app-submit-motion', + standalone: true, + imports: [MatButtonModule, MatIconModule, SubmitFlowLabComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './submit-motion.component.html', + styleUrl: './submit-motion.component.scss', +}) +export class SubmitMotionComponent { + @ViewChildren(SubmitFlowLabComponent) private panels?: QueryList; + + private readonly portfolio = inject(PortfolioCelebrationService); + + public readonly reducedMotion = prefersReducedMotion(); + + public readonly variants: VariantCard[] = [ + { + variant: 'blur', + name: '1. Blur', + idea: 'Everything softens away, changes underneath, and sharpens back.', + cost: 'What is shipped now. Safe, but the panel goes vague for a moment and nothing leads.', + }, + { + variant: 'lift', + name: '2. Lift', + idea: 'The old line leaves upward, the new one arrives from below, staggered.', + cost: 'Direction carries the sense of finishing. Two lines moving at once can read as busy.', + }, + { + variant: 'circle', + name: '3. Circle led', + idea: 'The mark grows first. The words clear out, then catch up to it.', + cost: 'The clearest subject of the three. The longest, at about 700ms end to end.', + }, + { + variant: 'wipe', + name: '4. Wipe', + idea: 'A clip runs down the text and back, so the words are uncovered, not faded.', + cost: 'Nothing moves, so a long headline stays steady. Subtle enough to miss.', + }, + { + variant: 'collapse', + name: '5. Collapse', + idea: 'The bar draws into the middle, the circle takes the hit, the mark draws out.', + cost: 'Treats the bar and the mark as one object. The most deliberate, and the slowest.', + }, + { + variant: 'settle', + name: '6. Settle', + idea: 'The whole panel dips and comes back as one gesture.', + cost: 'Simplest to reason about. Closest to a plain crossfade, so it says the least.', + }, + ]; + + public playAll(): void { + this.panels?.forEach((panel) => panel.play()); + } + + public resetAll(): void { + this.panels?.forEach((panel) => panel.reset()); + } + + /** The portfolio moment, without having to finish a unit to see it. */ + public playPortfolio(withGrade: boolean): void { + this.portfolio.show({ + unitCode: 'SIT374', + unitName: 'Team Project (A)', + headline: 'Portfolio submitted', + detail: + 'That is the whole unit done. Your portfolio is everything you have worked on this trimester, in one document.', + facts: withGrade + ? [ + {icon: 'task_alt', label: 'Work included', value: '11 tasks'}, + {icon: 'workspace_premium', label: 'Submitted for', value: 'High Distinction'}, + ] + : [{icon: 'task_alt', label: 'Work included', value: '1 task'}], + next: 'It is being built now. That takes a little while, and you will get an email when it is ready to download.', + }); + } +} diff --git a/src/app/common/celebrate/portfolio-celebration.component.html b/src/app/common/celebrate/portfolio-celebration.component.html new file mode 100644 index 0000000000..9ec32886ba --- /dev/null +++ b/src/app/common/celebrate/portfolio-celebration.component.html @@ -0,0 +1,35 @@ +
+
+ + +

{{ data.unitCode }}

+

{{ data.headline }}

+

{{ data.detail }}

+
+ + + @if (data.facts.length) { +
+ @for (fact of data.facts; track fact.label; let index = $index) { +
+ +
{{ fact.label }}
+
{{ fact.value }}
+
+ } +
+ } + +

+ + {{ data.next }} +

+
+ + + + +
diff --git a/src/app/common/celebrate/portfolio-celebration.component.scss b/src/app/common/celebrate/portfolio-celebration.component.scss new file mode 100644 index 0000000000..c1bf570c91 --- /dev/null +++ b/src/app/common/celebrate/portfolio-celebration.component.scss @@ -0,0 +1,236 @@ +// The end of a unit. Everything here is slower than the rest of the app on +// purpose: the mark draws at 2.6x the usual pace, the words arrive after it has +// landed, and the facts settle one at a time. Nothing times out. +@use './motion' as motion; + +$portfolio-ease-out: cubic-bezier(0.23, 1, 0.32, 1); + +.ot-portfolio-dialog { + // Material reads its own token for this. Setting justify-content on the + // actions loses to the rule that reads the token, so the button stayed right. + --mat-dialog-actions-alignment: center; +} + +.ot-portfolio-dialog .mat-mdc-dialog-surface { + overflow: hidden; + border-radius: var(--ot-radius-lg, 16px); + background: var(--ot-color-surface); +} + +.ot-portfolio { + display: flex; + flex-direction: column; + // The surface is capped by the dialog config, so the middle scrolls rather + // than the ends being cut off on a short window. + min-height: 0; + max-height: 100%; + color: var(--ot-color-text); + text-align: center; +} + +// ---- The mark and the words ---- +.ot-portfolio__stage { + display: flex; + flex: none; + flex-direction: column; + align-items: center; + padding: 48px 24px 8px; + // A soft wash behind the mark, so the top of the dialog is the subject. + background: radial-gradient( + 120% 90% at 50% 0%, + color-mix(in srgb, var(--ot-color-success) 14%, transparent), + transparent 70% + ); +} + +.ot-portfolio__mark { + position: relative; + display: inline-flex; +} + +.ot-portfolio__eyebrow { + margin: 24px 0 0; + color: var(--ot-color-text-muted); + font-size: 0.75rem; + font-weight: 650; + letter-spacing: 0.12em; + text-transform: uppercase; + animation: portfolio-rise 360ms $portfolio-ease-out both; + animation-delay: 1050ms; +} + +.ot-portfolio__headline { + max-width: 16ch; + margin: 8px 0 0; + color: var(--ot-color-text); + font-size: clamp(1.75rem, 6vw, 2.5rem); + font-weight: 700; + line-height: 1.12; + letter-spacing: -0.025em; + animation: portfolio-rise 420ms $portfolio-ease-out both; + animation-delay: 1180ms; +} + +.ot-portfolio__detail { + max-width: 38ch; + margin: 12px 0 0; + color: var(--ot-color-text-muted); + font-size: 0.95rem; + line-height: 1.55; + animation: portfolio-rise 380ms $portfolio-ease-out both; + animation-delay: 1320ms; +} + +// ---- The facts ---- +.ot-portfolio__content.mat-mdc-dialog-content { + display: flex; + flex: 1 1 auto; + flex-direction: column; + gap: 16px; + min-height: 0; + max-height: none; + padding: 24px; + color: inherit; +} + +.ot-portfolio__facts { + display: grid; + gap: 8px; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 13rem), 1fr)); + margin: 0; +} + +.ot-portfolio__fact { + display: grid; + align-items: center; + gap: 0 12px; + grid-template-areas: + 'icon label' + 'icon value'; + grid-template-columns: auto minmax(0, 1fr); + padding: 12px 14px; + border: 1px solid var(--ot-color-border); + border-radius: var(--ot-radius-md, 10px); + background: var(--ot-color-surface-raised); + text-align: start; + animation: portfolio-rise 320ms $portfolio-ease-out both; + animation-delay: var(--fact-delay, 1500ms); + + mat-icon { + grid-area: icon; + flex: none; + width: 22px; + height: 22px; + color: var(--ot-color-success); + font-size: 22px; + line-height: 22px; + overflow: visible; + } + + dt { + grid-area: label; + margin: 0; + color: var(--ot-color-text-muted); + font-size: 0.7rem; + font-weight: 650; + letter-spacing: 0.05em; + line-height: 1.4; + text-transform: uppercase; + } + + dd { + grid-area: value; + margin: 0; + color: var(--ot-color-text); + font-size: 0.95rem; + font-weight: 600; + line-height: 1.35; + overflow-wrap: anywhere; + } +} + +.ot-portfolio__next { + display: flex; + align-items: flex-start; + gap: 10px; + margin: 0; + padding: 14px; + border-radius: var(--ot-radius-md, 10px); + background: var(--ot-color-surface-raised); + color: var(--ot-color-text-muted); + font-size: 0.875rem; + line-height: 1.5; + text-align: start; + animation: portfolio-rise 320ms $portfolio-ease-out both; + animation-delay: 2100ms; + + mat-icon { + // Without flex: none the glyph is squashed by the text beside it and the + // clock loses its right edge. + flex: none; + width: 18px; + height: 18px; + margin-block-start: 1px; + font-size: 18px; + line-height: 18px; + overflow: visible; + } +} + +// ---- Leaving ---- +// No timer. It waits, because this is the one moment worth staying on. +.ot-portfolio__actions.mat-mdc-dialog-actions { + flex: none; + justify-content: center; + padding: 0 24px max(28px, env(safe-area-inset-bottom, 0px)); + animation: portfolio-rise 320ms $portfolio-ease-out both; + animation-delay: 2300ms; + + button { + min-width: 140px; + transition: transform 140ms $portfolio-ease-out; + + &:active { + transform: scale(0.97); + } + } +} + +@keyframes portfolio-rise { + from { + opacity: 0; + transform: translateY(12px); + } + + to { + opacity: 1; + transform: none; + } +} + +@mixin portfolio-still { + .ot-portfolio__eyebrow, + .ot-portfolio__headline, + .ot-portfolio__detail, + .ot-portfolio__fact, + .ot-portfolio__next, + .ot-portfolio__actions.mat-mdc-dialog-actions { + animation: none; + opacity: 1; + transform: none; + } + + .ot-portfolio__actions button { + transition: none; + } +} + +.ot-portfolio--still { + @include portfolio-still; +} + +@include motion.reduced-motion { + .ot-portfolio { + @include portfolio-still; + } +} diff --git a/src/app/common/celebrate/portfolio-celebration.component.ts b/src/app/common/celebrate/portfolio-celebration.component.ts new file mode 100644 index 0000000000..d2be5104fc --- /dev/null +++ b/src/app/common/celebrate/portfolio-celebration.component.ts @@ -0,0 +1,116 @@ +import { + ChangeDetectionStrategy, + Component, + OnDestroy, + OnInit, + ViewEncapsulation, + inject, +} from '@angular/core'; +import {MatButtonModule} from '@angular/material/button'; +import {MAT_DIALOG_DATA, MatDialogModule, MatDialogRef} from '@angular/material/dialog'; +import {MatIconModule} from '@angular/material/icon'; +import {ConfettiService} from '../services/confetti.service'; +import {AnimatedCheckComponent} from './animated-check.component'; +import {CelebrationParticlesComponent} from './celebration-particles.component'; +import {prefersReducedMotion} from './reduced-motion'; + +/** Fired once the mark has drawn, so the two do not arrive on the same beat. */ +const CONFETTI_DELAYS_MS = [1400, 1720, 2100]; + +/** Above the CDK overlay, or the confetti falls behind the dialog. */ +const CONFETTI_Z_INDEX = 1200; + +export interface PortfolioCelebrationFact { + icon: string; + label: string; + value: string; +} + +export interface PortfolioCelebrationData { + unitCode: string; + unitName?: string; + headline: string; + detail: string; + facts: PortfolioCelebrationFact[]; + /** What happens between pressing the button and the document existing. */ + next: string; +} + +/** + * The end of a whole unit's work. A student reaches this once per unit per + * trimester, which is rarer than anything else the app celebrates, so it is the + * one moment allowed to take its time and to wait for the person rather than + * timing itself out. Nothing here dismisses on its own. + */ +@Component({ + selector: 'f-portfolio-celebration', + standalone: true, + imports: [ + MatDialogModule, + MatButtonModule, + MatIconModule, + AnimatedCheckComponent, + CelebrationParticlesComponent, + ], + changeDetection: ChangeDetectionStrategy.OnPush, + // The dialog surface is styled from here, every selector under .ot-portfolio + // so nothing leaks out of it. + encapsulation: ViewEncapsulation.None, + templateUrl: './portfolio-celebration.component.html', + styleUrl: './portfolio-celebration.component.scss', +}) +export class PortfolioCelebrationComponent implements OnInit, OnDestroy { + public readonly data = inject(MAT_DIALOG_DATA); + private readonly dialogRef = inject>(MatDialogRef); + private readonly confetti = inject(ConfettiService); + + public readonly reducedMotion = prefersReducedMotion(); + + private timers: ReturnType[] = []; + + public ngOnInit(): void { + if (this.reducedMotion) { + return; + } + + // Two cannons from the lower corners, then one overhead. Fired on a timer + // rather than all at once, so it falls through the dialog rather than + // flashing behind it. + const over = {zIndex: CONFETTI_Z_INDEX}; + this.fire(CONFETTI_DELAYS_MS[0], () => + this.confetti.canon(0.05, 0.95, 60, {...over, particleCount: 120, spread: 70}), + ); + this.fire(CONFETTI_DELAYS_MS[1], () => + this.confetti.canon(0.95, 0.95, 120, {...over, particleCount: 120, spread: 70}), + ); + this.fire(CONFETTI_DELAYS_MS[2], () => + this.confetti.canon(0.5, 0.28, 270, {...over, particleCount: 90, spread: 110, scalar: 0.9}), + ); + } + + public ngOnDestroy(): void { + this.timers.forEach(clearTimeout); + this.timers = []; + } + + private fire(delay: number, shot: () => void): void { + this.timers.push( + setTimeout(() => { + try { + shot(); + } catch { + // Confetti is decoration. It never takes the dialog down with it. + } + }, delay), + ); + } + + /** Rows settle in one after another. Held to a handful, so it stays a beat. */ + public factDelay(index: number): string { + return this.reducedMotion ? '0ms' : `${1500 + Math.min(index, 4) * 140}ms`; + } + + public close(): void { + this.dialogRef.close(); + } +} diff --git a/src/app/common/celebrate/portfolio-celebration.service.spec.ts b/src/app/common/celebrate/portfolio-celebration.service.spec.ts new file mode 100644 index 0000000000..c741e7fafd --- /dev/null +++ b/src/app/common/celebrate/portfolio-celebration.service.spec.ts @@ -0,0 +1,73 @@ +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {TestBed} from '@angular/core/testing'; +import {MatDialog} from '@angular/material/dialog'; +import type {Project} from 'src/app/api/models/project'; +import type {Task} from 'src/app/api/models/task'; +import {PortfolioCelebrationService} from './portfolio-celebration.service'; + +describe('PortfolioCelebrationService', () => { + let service: PortfolioCelebrationService; + let open: ReturnType; + + const build = (overrides: Partial = {}): Project => + ({ + unit: {code: 'SIT374', name: 'Team Project (A)', myRole: 'Student'}, + targetGradeWord: 'High Distinction', + ...overrides, + }) as unknown as Project; + + const tasks = (count: number): Task[] => + Array.from({length: count}, (_, index) => ({id: index}) as unknown as Task); + + beforeEach(() => { + open = vi.fn(); + TestBed.configureTestingModule({ + providers: [PortfolioCelebrationService, {provide: MatDialog, useValue: {open}}], + }); + service = TestBed.inject(PortfolioCelebrationService); + }); + + it('says what was sent and what happens next', () => { + const data = service.describe(build(), tasks(11)); + + expect(data?.unitCode).toBe('SIT374'); + expect(data?.headline).toBe('Portfolio submitted'); + expect(data?.facts).toEqual([ + {icon: 'task_alt', label: 'Work included', value: '11 tasks'}, + {icon: 'workspace_premium', label: 'Submitted for', value: 'High Distinction'}, + ]); + expect(data?.next).toContain('email'); + }); + + it('counts one task as a task, not as tasks', () => { + const data = service.describe(build(), tasks(1)); + expect(data?.facts[0].value).toBe('1 task'); + }); + + it('leaves out a fact it has no answer for', () => { + const data = service.describe(build({targetGradeWord: undefined} as never), []); + expect(data?.facts).toEqual([]); + }); + + it('is only for the student whose portfolio it is', () => { + const staff = build({unit: {code: 'SIT374', myRole: 'Tutor'}} as never); + + expect(service.describe(staff, tasks(3))).toBeNull(); + expect(service.celebrate(staff, tasks(3))).toBe(false); + expect(open).not.toHaveBeenCalled(); + }); + + it('opens the dialog for a student and reports that it did', () => { + expect(service.celebrate(build(), tasks(3))).toBe(true); + expect(open).toHaveBeenCalledTimes(1); + + // No timer: it waits to be dismissed, unlike the submission confirmation. + const config = open.mock.calls[0][1]; + expect(config.panelClass).toBe('ot-portfolio-dialog'); + expect(config.data.facts[0].value).toBe('3 tasks'); + }); + + it('never throws out of a submission that otherwise worked', () => { + expect(service.celebrate(null as unknown as Project, tasks(2))).toBe(false); + }); +}); diff --git a/src/app/common/celebrate/portfolio-celebration.service.ts b/src/app/common/celebrate/portfolio-celebration.service.ts new file mode 100644 index 0000000000..11fd495203 --- /dev/null +++ b/src/app/common/celebrate/portfolio-celebration.service.ts @@ -0,0 +1,96 @@ +import {Injectable, inject} from '@angular/core'; +import {MatDialog} from '@angular/material/dialog'; +import type {Project} from 'src/app/api/models/project'; +import type {Task} from 'src/app/api/models/task'; +import { + PortfolioCelebrationComponent, + PortfolioCelebrationData, + PortfolioCelebrationFact, +} from './portfolio-celebration.component'; +import {prefersReducedMotion} from './reduced-motion'; + +/** + * Marks the end of a unit's work, when a student sends their portfolio to be + * built. It is the rarest thing a student does here, so unlike the submission + * confirmation it waits to be dismissed rather than timing out. + */ +@Injectable({ + providedIn: 'root', +}) +export class PortfolioCelebrationService { + private readonly dialog = inject(MatDialog); + + /** Returns true when a celebration was shown, so the caller can skip its own toast. */ + public celebrate(project: Project, includedTasks: Task[]): boolean { + try { + const data = this.describe(project, includedTasks); + if (!data) { + return false; + } + + this.show(data); + return true; + } catch { + return false; + } + } + + /** What the dialog says, or null when this viewer should not see one. */ + public describe(project: Project, includedTasks: Task[]): PortfolioCelebrationData | null { + if (project?.unit?.myRole !== 'Student') { + return null; + } + + const count = includedTasks?.length ?? 0; + const facts: PortfolioCelebrationFact[] = []; + + if (count > 0) { + facts.push({ + icon: 'task_alt', + label: 'Work included', + value: `${count} ${count === 1 ? 'task' : 'tasks'}`, + }); + } + + const grade = this.gradeLabel(project); + if (grade) { + facts.push({icon: 'workspace_premium', label: 'Submitted for', value: grade}); + } + + return { + unitCode: project.unit?.code ?? '', + unitName: project.unit?.name ?? undefined, + headline: 'Portfolio submitted', + detail: + 'That is the whole unit done. Your portfolio is everything you have worked on this trimester, in one document.', + facts, + next: 'It is being built now. That takes a little while, and you will get an email when it is ready to download.', + }; + } + + public show(data: PortfolioCelebrationData): void { + const reduced = prefersReducedMotion(); + this.dialog.open( + PortfolioCelebrationComponent, + { + data, + width: 'min(560px, calc(100vw - 32px))', + maxWidth: '560px', + maxHeight: 'calc(100dvh - 32px)', + panelClass: 'ot-portfolio-dialog', + autoFocus: 'first-tabbable', + restoreFocus: true, + enterAnimationDuration: reduced ? '120ms' : '320ms', + exitAnimationDuration: reduced ? '100ms' : '180ms', + }, + ); + } + + private gradeLabel(project: Project): string | undefined { + try { + return project.targetGradeWord || undefined; + } catch { + return undefined; + } + } +} diff --git a/src/app/common/celebrate/reduced-motion.ts b/src/app/common/celebrate/reduced-motion.ts new file mode 100644 index 0000000000..48467ad321 --- /dev/null +++ b/src/app/common/celebrate/reduced-motion.ts @@ -0,0 +1,12 @@ +/** True when the viewer asked the system for less motion. Safe without a window. */ +export function prefersReducedMotion(): boolean { + try { + return ( + typeof window !== 'undefined' && + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches + ); + } catch { + return false; + } +} diff --git a/src/app/common/celebrate/submission-celebration.component.html b/src/app/common/celebrate/submission-celebration.component.html new file mode 100644 index 0000000000..d98c10f2f6 --- /dev/null +++ b/src/app/common/celebrate/submission-celebration.component.html @@ -0,0 +1,22 @@ +@if (celebration(); as view) { + +} diff --git a/src/app/common/celebrate/submission-celebration.component.scss b/src/app/common/celebrate/submission-celebration.component.scss new file mode 100644 index 0000000000..e5ddc3feec --- /dev/null +++ b/src/app/common/celebrate/submission-celebration.component.scss @@ -0,0 +1,157 @@ +@use './motion' as motion; + +:host { + @include motion.tokens; + + display: block; + max-width: min(420px, calc(100vw - 32px)); + animation: toast-enter motion.$duration-enter motion.$ease-out both; +} + +:host(.leaving) { + animation: toast-exit motion.$duration-exit motion.$ease-out both; + pointer-events: none; +} + +.toast { + --toast-accent: var(--ot-color-success); + + display: flex; + align-items: center; + gap: 14px; + width: 100%; + padding: 12px 18px 12px 12px; + border: 1px solid var(--ot-color-divider); + border-inline-start: 3px solid var(--toast-accent); + border-radius: 12px; + background: var(--ot-color-surface-raised); + color: var(--ot-color-text); + // theme shadows, black at 14% vanished on the dark ground + box-shadow: + var(--ot-elevation-1), + 0 8px 24px var(--ot-scrim); + font: inherit; + text-align: start; + cursor: pointer; + + @include motion.press; + + &:focus-visible { + outline: 2px solid var(--ot-color-focus); + outline-offset: 2px; + } +} + +.toast.tone-primary { + --toast-accent: var(--ot-color-primary); +} + +.toast.tone-neutral { + --toast-accent: var(--ot-color-divider); +} + +.mark { + position: relative; + display: inline-flex; + flex: none; +} + +.copy { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + animation: celebrate-rise motion.$duration-enter motion.$ease-out both; + animation-delay: 420ms; +} + +.headline { + font-size: 15px; + font-weight: 600; + line-height: 1.3; +} + +.detail { + color: var(--ot-color-text-muted); + font-size: 13px; + line-height: 1.35; + overflow-wrap: anywhere; +} + +@keyframes toast-enter { + from { + opacity: 0; + transform: translateY(8px) scale(0.96); + } + + to { + opacity: 1; + transform: none; + } +} + +@keyframes toast-exit { + from { + opacity: 1; + transform: none; + } + + to { + opacity: 0; + transform: translateY(4px) scale(0.98); + } +} + +@keyframes toast-fade-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes toast-fade-out { + from { + opacity: 1; + } + + to { + opacity: 0; + } +} + +@mixin still-inner { + .copy { + animation: toast-fade-in 120ms linear both; + } + + .toast, + .toast:active { + transition: none; + transform: none; + } +} + +:host(.reduced-motion) { + animation: toast-fade-in 120ms linear both; + + @include still-inner; +} + +:host(.reduced-motion.leaving) { + animation: toast-fade-out 120ms linear both; +} + +@include motion.reduced-motion { + :host { + animation: toast-fade-in 120ms linear both; + + @include still-inner; + } + + :host(.leaving) { + animation: toast-fade-out 120ms linear both; + } +} diff --git a/src/app/common/celebrate/submission-celebration.component.ts b/src/app/common/celebrate/submission-celebration.component.ts new file mode 100644 index 0000000000..4c87bdb202 --- /dev/null +++ b/src/app/common/celebrate/submission-celebration.component.ts @@ -0,0 +1,37 @@ +import {ChangeDetectionStrategy, Component, input, output, signal} from '@angular/core'; +import {AnimatedCheckComponent} from './animated-check.component'; +import {CelebrationParticlesComponent} from './celebration-particles.component'; +import {prefersReducedMotion} from './reduced-motion'; +import type {SubmissionCelebration} from './submission-timing'; + +/** Exit length in ms. The service waits this long before removing the overlay. */ +export const SUBMISSION_CELEBRATION_EXIT_MS = 150; + +/** + * The short confirmation shown after a task moves to Ready for Feedback. It sits + * at the bottom of the screen without a backdrop, so the page stays usable while + * it plays. The service announces the text, so this element is not a live region. + */ +@Component({ + selector: 'f-submission-celebration', + standalone: true, + imports: [AnimatedCheckComponent, CelebrationParticlesComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './submission-celebration.component.html', + styleUrl: './submission-celebration.component.scss', + host: { + '[class.leaving]': 'leaving()', + '[class.reduced-motion]': 'reducedMotion', + }, +}) +export class SubmissionCelebrationComponent { + public readonly celebration = input(null); + public readonly dismiss = output(); + + public readonly reducedMotion = prefersReducedMotion(); + public readonly leaving = signal(false); + + public leave(): void { + this.leaving.set(true); + } +} diff --git a/src/app/common/celebrate/submission-celebration.service.ts b/src/app/common/celebrate/submission-celebration.service.ts new file mode 100644 index 0000000000..6e9a1c051f --- /dev/null +++ b/src/app/common/celebrate/submission-celebration.service.ts @@ -0,0 +1,134 @@ +import {LiveAnnouncer} from '@angular/cdk/a11y'; +import {OverlayRef, createGlobalPositionStrategy, createOverlayRef} from '@angular/cdk/overlay'; +import {ComponentPortal} from '@angular/cdk/portal'; +import {ComponentRef, Injectable, Injector, inject} from '@angular/core'; +import type {Task} from 'src/app/api/models/task'; +import type {TaskStatusEnum} from 'src/app/api/models/task-status'; +import { + SUBMISSION_CELEBRATION_EXIT_MS, + SubmissionCelebrationComponent, +} from './submission-celebration.component'; +import { + RESUBMIT_FROM_STATUSES, + SubmissionCelebration, + buildSubmissionCelebration, + classifySubmissionTiming, +} from './submission-timing'; + +/** How long the confirmation stays before it leaves on its own. */ +export const SUBMISSION_CELEBRATION_VISIBLE_MS = 2500; + +/** + * Shows the Ready for Feedback confirmation. Only a student's own submission gets + * one. It dismisses itself after a moment, or on click or Escape. + */ +@Injectable({ + providedIn: 'root', +}) +export class SubmissionCelebrationService { + private readonly injector = inject(Injector); + private readonly announcer = inject(LiveAnnouncer); + + private overlayRef: OverlayRef | null = null; + private componentRef: ComponentRef | null = null; + private dismissTimer: ReturnType | null = null; + private readonly onKeydown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + this.dismiss(); + } + }; + + /** + * Describes the celebration for a task that has just become Ready for Feedback, + * or null when this viewer should not see one. + */ + public describe( + task: Task, + previousStatus: TaskStatusEnum | undefined, + now: Date = new Date(), + ): SubmissionCelebration | null { + if (task?.unit?.myRole !== 'Student') { + return null; + } + + const timing = classifySubmissionTiming(now, task.localDueDate(), task.localDeadlineDate()); + + return buildSubmissionCelebration({ + timing: task.status === 'time_exceeded' ? 'after_due' : timing, + resubmission: !!previousStatus && RESUBMIT_FROM_STATUSES.includes(previousStatus), + abbreviation: task.definition?.abbreviation ?? '', + name: task.definition?.name ?? '', + }); + } + + /** Returns true when a celebration was shown, so the caller can skip its own toast. */ + public celebrate(task: Task, previousStatus: TaskStatusEnum | undefined): boolean { + try { + const celebration = this.describe(task, previousStatus); + if (!celebration) { + return false; + } + + this.show(celebration); + return true; + } catch { + return false; + } + } + + public show(celebration: SubmissionCelebration): void { + this.dispose(); + + const positionStrategy = createGlobalPositionStrategy(this.injector) + .centerHorizontally() + .bottom('calc(24px + env(safe-area-inset-bottom, 0px))'); + this.overlayRef = createOverlayRef(this.injector, { + positionStrategy, + hasBackdrop: false, + panelClass: 'ot-submission-celebration-pane', + }); + + const componentRef = this.overlayRef.attach( + new ComponentPortal(SubmissionCelebrationComponent, null, this.injector), + ); + componentRef.setInput('celebration', celebration); + componentRef.instance.dismiss.subscribe(() => this.dismiss()); + this.componentRef = componentRef; + + document.addEventListener('keydown', this.onKeydown); + this.dismissTimer = setTimeout(() => this.dismiss(), SUBMISSION_CELEBRATION_VISIBLE_MS); + + void this.announcer.announce(`${celebration.headline}. ${celebration.detail}`, 'polite'); + } + + public dismiss(): void { + const componentRef = this.componentRef; + const overlayRef = this.overlayRef; + if (!componentRef || !overlayRef) { + return; + } + + this.clearListeners(); + componentRef.instance.leave(); + this.componentRef = null; + this.overlayRef = null; + + const exitMs = componentRef.instance.reducedMotion ? 120 : SUBMISSION_CELEBRATION_EXIT_MS; + setTimeout(() => overlayRef.dispose(), exitMs); + } + + private dispose(): void { + this.clearListeners(); + this.overlayRef?.dispose(); + this.overlayRef = null; + this.componentRef = null; + } + + private clearListeners(): void { + document.removeEventListener('keydown', this.onKeydown); + if (this.dismissTimer) { + clearTimeout(this.dismissTimer); + this.dismissTimer = null; + } + } +} diff --git a/src/app/common/celebrate/submission-timing.spec.ts b/src/app/common/celebrate/submission-timing.spec.ts new file mode 100644 index 0000000000..602090c229 --- /dev/null +++ b/src/app/common/celebrate/submission-timing.spec.ts @@ -0,0 +1,139 @@ +import {afterEach, describe, expect, it} from 'vitest'; +import {TestBed} from '@angular/core/testing'; +import type {Task} from 'src/app/api/models/task'; +import {SubmissionCelebrationService} from './submission-celebration.service'; +import {buildSubmissionCelebration, classifySubmissionTiming} from './submission-timing'; + +describe('submission timing', () => { + const target = new Date(2026, 8, 10, 0, 0, 0); + const due = new Date(2026, 8, 24, 0, 0, 0); + + it('counts anything up to the end of the target day as on time', () => { + expect(classifySubmissionTiming(new Date(2026, 8, 3), target, due)).toBe('on_time'); + expect(classifySubmissionTiming(new Date(2026, 8, 10, 23, 30), target, due)).toBe('on_time'); + }); + + it('is after the target once the target day has passed, until the due day ends', () => { + expect(classifySubmissionTiming(new Date(2026, 8, 11, 0, 5), target, due)).toBe('after_target'); + expect(classifySubmissionTiming(new Date(2026, 8, 24, 22, 0), target, due)).toBe( + 'after_target', + ); + }); + + it('is after the due date once the due day has passed', () => { + expect(classifySubmissionTiming(new Date(2026, 8, 25, 0, 1), target, due)).toBe('after_due'); + }); + + it('does not invent lateness when a date is missing', () => { + expect(classifySubmissionTiming(new Date(2026, 8, 20), undefined, due)).toBe('on_time'); + expect(classifySubmissionTiming(new Date(2026, 8, 20), target, null)).toBe('after_target'); + }); + + it('celebrates an on time submission with the task named', () => { + const view = buildSubmissionCelebration({ + timing: 'on_time', + resubmission: false, + abbreviation: '2.1P', + name: 'Sorting', + }); + + expect(view.headline).toBe('Submitted on time. Ready for feedback'); + expect(view.detail).toBe('2.1P Sorting'); + expect(view.tone).toBe('success'); + expect(view.particles).toBe(true); + }); + + it('stays positive but calmer after the target date', () => { + const view = buildSubmissionCelebration({ + timing: 'after_target', + resubmission: false, + abbreviation: '2.1P', + name: 'Sorting', + }); + + expect(view.headline).toBe('Submitted. Ready for feedback'); + expect(view.detail).toContain('Your tutor will review it'); + expect(view.tone).toBe('primary'); + expect(view.particles).toBe(false); + }); + + it('is neutral with no particles after the due date', () => { + const view = buildSubmissionCelebration({ + timing: 'after_due', + resubmission: false, + abbreviation: '2.1P', + name: 'Sorting', + }); + + expect(view.headline).toBe('Submitted after the due date'); + expect(view.tone).toBe('neutral'); + expect(view.particles).toBe(false); + }); + + it('names a resubmission and keeps the same timing rules', () => { + const onTime = buildSubmissionCelebration({ + timing: 'on_time', + resubmission: true, + abbreviation: '2.1P', + name: 'Sorting', + }); + const late = buildSubmissionCelebration({ + timing: 'after_due', + resubmission: true, + abbreviation: '2.1P', + name: 'Sorting', + }); + + expect(onTime.headline).toBe('Resubmitted. Ready for feedback'); + expect(onTime.tone).toBe('success'); + expect(late.headline).toBe('Resubmitted after the due date'); + expect(late.particles).toBe(false); + }); +}); + +describe('SubmissionCelebrationService.describe', () => { + afterEach(() => TestBed.resetTestingModule()); + + const makeTask = (role: string, status = 'ready_for_feedback') => + ({ + status, + unit: {myRole: role}, + definition: {abbreviation: '3.1C', name: 'Graphs'}, + localDueDate: () => new Date(2026, 8, 10), + localDeadlineDate: () => new Date(2026, 8, 24), + }) as unknown as Task; + + it('reads the task dates and the previous status', () => { + const service = TestBed.inject(SubmissionCelebrationService); + + const onTime = service.describe(makeTask('Student'), 'working_on_it', new Date(2026, 8, 9)); + const resubmitted = service.describe( + makeTask('Student'), + 'fix_and_resubmit', + new Date(2026, 8, 15), + ); + + expect(onTime?.timing).toBe('on_time'); + expect(onTime?.resubmission).toBe(false); + expect(resubmitted?.timing).toBe('after_target'); + expect(resubmitted?.headline).toBe('Resubmitted. Ready for feedback'); + }); + + it('treats a time exceeded response as after the due date', () => { + const service = TestBed.inject(SubmissionCelebrationService); + const view = service.describe( + makeTask('Student', 'time_exceeded'), + 'working_on_it', + new Date(2026, 8, 9), + ); + + expect(view?.timing).toBe('after_due'); + }); + + it('never celebrates for staff', () => { + const service = TestBed.inject(SubmissionCelebrationService); + + expect(service.describe(makeTask('Tutor'), 'working_on_it')).toBeNull(); + expect(service.celebrate(makeTask('Convenor'), 'working_on_it')).toBe(false); + }); +}); diff --git a/src/app/common/celebrate/submission-timing.ts b/src/app/common/celebrate/submission-timing.ts new file mode 100644 index 0000000000..34d6a1954a --- /dev/null +++ b/src/app/common/celebrate/submission-timing.ts @@ -0,0 +1,96 @@ +import type {CelebrateTone} from './animated-check.component'; + +/** + * When a submission landed, relative to the task's two dates. The target date is + * the date the student planned to submit by (`Task.localDueDate()`), and the due + * date is the final deadline for feedback (`Task.localDeadlineDate()`). + */ +export type SubmissionTiming = 'on_time' | 'after_target' | 'after_due'; + +export interface SubmissionCelebration { + timing: SubmissionTiming; + resubmission: boolean; + tone: CelebrateTone; + particles: boolean; + headline: string; + detail: string; +} + +/** Statuses a student resubmits from after a tutor has asked for changes. */ +export const RESUBMIT_FROM_STATUSES: readonly string[] = ['fix_and_resubmit', 'redo']; + +/** + * Dates in OnTrack are shown as whole days, so anything submitted during the + * target or due day itself counts as on that day. + */ +function endOfDay(date: Date): number { + const end = new Date(date.getTime()); + end.setHours(23, 59, 59, 999); + return end.getTime(); +} + +function validDate(date: Date | null | undefined): date is Date { + return date instanceof Date && !Number.isNaN(date.getTime()); +} + +export function classifySubmissionTiming( + submittedAt: Date, + targetDate: Date | null | undefined, + dueDate: Date | null | undefined, +): SubmissionTiming { + const at = submittedAt.getTime(); + + if (validDate(dueDate) && at > endOfDay(dueDate)) { + return 'after_due'; + } + + if (validDate(targetDate) && at > endOfDay(targetDate)) { + return 'after_target'; + } + + return 'on_time'; +} + +export function buildSubmissionCelebration(options: { + timing: SubmissionTiming; + resubmission: boolean; + abbreviation: string; + name: string; +}): SubmissionCelebration { + const {timing, resubmission} = options; + const taskLabel = [options.abbreviation, options.name].filter(Boolean).join(' '); + const verb = resubmission ? 'Resubmitted' : 'Submitted'; + + switch (timing) { + case 'on_time': + return { + timing, + resubmission, + tone: 'success', + particles: true, + headline: resubmission + ? 'Resubmitted. Ready for feedback' + : 'Submitted on time. Ready for feedback', + detail: taskLabel, + }; + case 'after_target': + return { + timing, + resubmission, + tone: 'primary', + particles: false, + headline: `${verb}. Ready for feedback`, + detail: `${taskLabel}. Your tutor will review it.`, + }; + case 'after_due': + default: + return { + timing: 'after_due', + resubmission, + tone: 'neutral', + particles: false, + headline: `${verb} after the due date`, + detail: `${taskLabel} is uploaded. Your tutor can help with what happens next.`, + }; + } +} diff --git a/src/app/common/celebrate/task-status-seen.service.spec.ts b/src/app/common/celebrate/task-status-seen.service.spec.ts new file mode 100644 index 0000000000..5c526a6890 --- /dev/null +++ b/src/app/common/celebrate/task-status-seen.service.spec.ts @@ -0,0 +1,107 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {TaskStatusSeenService, TaskStatusSnapshot} from './task-status-seen.service'; + +describe('TaskStatusSeenService', () => { + let service: TaskStatusSeenService; + + const snapshot = (taskDefinitionId: number, status: TaskStatusSnapshot['status']) => ({ + taskDefinitionId, + status, + }); + + let store: Map; + let storage: {getItem: ReturnType; setItem: ReturnType}; + + beforeEach(() => { + store = new Map(); + storage = { + getItem: vi.fn((key: string) => store.get(key) ?? null), + setItem: vi.fn((key: string, value: string) => void store.set(key, value)), + }; + vi.stubGlobal('localStorage', storage); + service = new TaskStatusSeenService(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('uses a key scoped to the user and the project', () => { + expect(service.storageKey(5, 12)).toBe('ontrack.taskStatusSeen.5.12'); + }); + + it('treats a first visit as silent, with nothing to celebrate', () => { + const changes = service.detectChanges(5, 12, [snapshot(1, 'complete'), snapshot(2, 'discuss')]); + + expect(changes.firstVisit).toBe(true); + expect(changes.completed).toEqual([]); + expect(changes.readyToDiscuss).toEqual([]); + }); + + it('detects tasks that became complete since the last visit', () => { + service.record(5, 12, [ + snapshot(1, 'ready_for_feedback'), + snapshot(2, 'complete'), + snapshot(3, 'working_on_it'), + snapshot(4, 'ready_for_feedback'), + snapshot(5, 'ready_for_feedback'), + ]); + + const changes = service.detectChanges(5, 12, [ + snapshot(1, 'complete'), + snapshot(2, 'complete'), + snapshot(3, 'working_on_it'), + snapshot(4, 'discuss'), + snapshot(5, 'fix_and_resubmit'), + ]); + + expect(changes.firstVisit).toBe(false); + expect(changes.completed).toEqual([1]); + expect(changes.readyToDiscuss).toEqual([4]); + expect(changes.needsChanges).toEqual([5]); + expect(changes.previous['1']).toBe('ready_for_feedback'); + }); + + it('plays once, because recording the new statuses clears the change', () => { + service.record(5, 12, [snapshot(1, 'ready_for_feedback')]); + const current = [snapshot(1, 'complete')]; + + expect(service.detectChanges(5, 12, current).completed).toEqual([1]); + service.record(5, 12, current); + expect(service.detectChanges(5, 12, current).completed).toEqual([]); + }); + + it('keeps each user and project apart', () => { + service.record(5, 12, [snapshot(1, 'ready_for_feedback')]); + + expect(service.detectChanges(6, 12, [snapshot(1, 'complete')]).firstVisit).toBe(true); + expect(service.detectChanges(5, 13, [snapshot(1, 'complete')]).firstVisit).toBe(true); + expect(service.detectChanges(5, 12, [snapshot(1, 'complete')]).completed).toEqual([1]); + }); + + it('reads as a first visit and never throws when storage is unavailable', () => { + storage.getItem.mockImplementation(() => { + throw new Error('blocked'); + }); + storage.setItem.mockImplementation(() => { + throw new Error('blocked'); + }); + + expect(() => service.detectChanges(5, 12, [snapshot(1, 'complete')])).not.toThrow(); + expect(service.detectChanges(5, 12, [snapshot(1, 'complete')]).firstVisit).toBe(true); + expect(service.record(5, 12, [snapshot(1, 'complete')])).toBe(false); + }); + + it('ignores corrupt stored data and unknown statuses', () => { + store.set(service.storageKey(5, 12), '{not json'); + expect(service.read(5, 12)).toBeNull(); + + store.set(service.storageKey(5, 12), JSON.stringify({'1': 'complete', '2': 'made_up'})); + expect(service.read(5, 12)).toEqual({'1': 'complete'}); + }); + + it('does nothing without a signed-in user or a project', () => { + expect(service.record(0, 12, [snapshot(1, 'complete')])).toBe(false); + expect(service.detectChanges(Number.NaN, 12, []).firstVisit).toBe(true); + }); +}); diff --git a/src/app/common/celebrate/task-status-seen.service.ts b/src/app/common/celebrate/task-status-seen.service.ts new file mode 100644 index 0000000000..9f104b331e --- /dev/null +++ b/src/app/common/celebrate/task-status-seen.service.ts @@ -0,0 +1,126 @@ +import {Injectable} from '@angular/core'; +import {TaskStatus, TaskStatusEnum} from 'src/app/api/models/task-status'; + +/** Last status the student saw for each task, keyed by task definition id. */ +export type SeenStatusMap = Record; + +export interface TaskStatusSnapshot { + taskDefinitionId: number; + status: TaskStatusEnum; +} + +export interface TaskStatusChanges { + /** Nothing stored yet. Callers record silently and celebrate nothing. */ + firstVisit: boolean; + completed: number[]; + readyToDiscuss: number[]; + needsChanges: number[]; + previous: SeenStatusMap; +} + +const DISCUSS_STATUSES: TaskStatusEnum[] = ['discuss', 'demonstrate']; +const NEEDS_CHANGES_STATUSES: TaskStatusEnum[] = ['fix_and_resubmit', 'redo']; + +/** + * Remembers, per user and project, the task statuses a student last saw on their + * dashboard. Storage can be missing, full or blocked (private windows, embedded + * previews), so every access is guarded and a failure reads as a first visit. + */ +@Injectable({ + providedIn: 'root', +}) +export class TaskStatusSeenService { + public storageKey(userId: number, projectId: number): string { + return `ontrack.taskStatusSeen.${userId}.${projectId}`; + } + + public read(userId: number, projectId: number): SeenStatusMap | null { + if (!this.validIds(userId, projectId)) { + return null; + } + + try { + const raw = globalThis.localStorage.getItem(this.storageKey(userId, projectId)); + if (!raw) { + return null; + } + + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return null; + } + + const seen: SeenStatusMap = {}; + Object.entries(parsed as Record).forEach(([id, status]) => { + if (TaskStatus.isStatus(status)) { + seen[id] = status; + } + }); + return seen; + } catch { + return null; + } + } + + public record(userId: number, projectId: number, snapshots: TaskStatusSnapshot[]): boolean { + if (!this.validIds(userId, projectId)) { + return false; + } + + const seen: SeenStatusMap = {}; + snapshots.forEach((snapshot) => { + seen[String(snapshot.taskDefinitionId)] = snapshot.status; + }); + + try { + globalThis.localStorage.setItem(this.storageKey(userId, projectId), JSON.stringify(seen)); + return true; + } catch { + return false; + } + } + + /** Compares the current statuses with the stored ones. Does not write. */ + public detectChanges( + userId: number, + projectId: number, + snapshots: TaskStatusSnapshot[], + ): TaskStatusChanges { + const previous = this.read(userId, projectId); + const changes: TaskStatusChanges = { + firstVisit: previous === null, + completed: [], + readyToDiscuss: [], + needsChanges: [], + previous: previous ?? {}, + }; + + if (previous === null) { + return changes; + } + + snapshots.forEach(({taskDefinitionId, status}) => { + const before = previous[String(taskDefinitionId)]; + if (before === status) { + return; + } + + if (status === 'complete') { + changes.completed.push(taskDefinitionId); + } else if (DISCUSS_STATUSES.includes(status) && !DISCUSS_STATUSES.includes(before)) { + changes.readyToDiscuss.push(taskDefinitionId); + } else if ( + NEEDS_CHANGES_STATUSES.includes(status) && + !NEEDS_CHANGES_STATUSES.includes(before) + ) { + changes.needsChanges.push(taskDefinitionId); + } + }); + + return changes; + } + + private validIds(userId: number, projectId: number): boolean { + return Number.isFinite(userId) && userId > 0 && Number.isFinite(projectId) && projectId > 0; + } +}