+ 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.
+
+ 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.
+
+ 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/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) {
+
+ info
+ 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.icon }}
+
{{ fact.label }}
+
{{ fact.value }}
+
+ }
+
+ }
+
+
+ schedule
+ {{ 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;
+ }
+}